From 638f5a6602fb3e8e7750919928718d5ced478b24 Mon Sep 17 00:00:00 2001 From: Ben Senescu <44480372+bensenescu@users.noreply.github.com> Date: Wed, 25 Mar 2026 15:16:05 -0400 Subject: [PATCH] refactor: move lighthouse audits to dataforseo (#43) * refactor: move lighthouse audits to dataforseo * chore: remove obsolete audit settings modal * refactor: rename psi flows to lighthouse * save * refactor: simplify audit lighthouse storage flow * fix: separate lighthouse metrics from actionable audits * refactor: remove redundant audit project inputs * feat: redesign lighthouse issues screen with score gauges and table layout Replace flat score cards with circular SVG gauges, condense metrics into a compact grid, and switch issue list from cards to an expandable table with fixed column widths. * test: harden lighthouse regression coverage * fix: restore project-scoped audit inputs * refactor: simplify lighthouse payload handling * refactor: inline lighthouse server handlers * refactor: share audit workflow types * refactor: simplify lighthouse payload flows * save * refactor: drop project pagespeed api key * fix: restore lighthouse issues loading with resilient project context * fix: restore audit issues back navigation * refactor: simplify project context and lighthouse error handling * fix: tolerate DataForSEO lighthouse payload drift * refactor: route audit lighthouse through dataforseo client --- .gitignore | 3 + README.md | 2 +- drizzle/0005_low_red_hulk.sql | 30 + drizzle/0006_magical_alex_wilder.sql | 1 + drizzle/meta/0005_snapshot.json | 1526 +++++++++++++++++ drizzle/meta/0006_snapshot.json | 1513 ++++++++++++++++ drizzle/meta/_journal.json | 14 + .../audit/launch/AuditHistorySection.tsx | 4 +- .../features/audit/launch/LaunchFormCard.tsx | 75 +- .../features/audit/launch/LaunchView.tsx | 15 +- .../features/audit/launch/SettingsModal.tsx | 128 -- src/client/features/audit/launch/types.ts | 13 +- .../audit/launch/useLaunchController.ts | 141 +- .../features/audit/results/ResultsTables.tsx | 62 +- .../features/audit/results/ResultsView.tsx | 106 +- src/client/features/audit/results/export.ts | 41 +- src/client/features/audit/shared.tsx | 2 +- .../lighthouse/issues/LighthouseIssueRow.tsx | 164 ++ .../issues/LighthouseIssuesParts.tsx} | 168 +- .../issues/LighthouseIssuesScreen.tsx} | 111 +- .../issues/LighthouseIssuesSummary.tsx | 121 ++ .../features/lighthouse/issues/types.ts | 26 + .../features/lighthouse/issues/utils.tsx | 53 + src/client/features/psi/issues/types.ts | 28 - src/client/features/psi/issues/utils.tsx | 113 -- src/db/app.schema.ts | 20 +- .../_project/p/$projectId/audit/index.tsx | 34 +- .../p/$projectId/audit/issues/$resultId.tsx | 11 +- .../audit/repositories/AuditRepository.ts | 303 ++-- .../features/audit/services/AuditService.ts | 120 +- .../audit/services/audit-capacity.test.ts | 27 +- .../features/audit/services/audit-capacity.ts | 20 +- .../services/lighthouse-export.test.ts | 170 ++ .../repositories/ProjectRepository.ts | 25 - .../features/psi/services/PsiAuditService.ts | 118 -- .../features/psi/services/PsiIssuesService.ts | 230 --- .../features/psi/services/psi-export.ts | 52 - src/server/lib/audit/lighthouse.ts | 163 ++ src/server/lib/audit/psi.ts | 176 -- src/server/lib/audit/types.ts | 57 +- src/server/lib/dataforseoClient.ts | 10 + src/server/lib/dataforseoLighthouse.ts | 60 + .../lib/dataforseoLighthousePayload.test.ts | 250 +++ src/server/lib/dataforseoLighthousePayload.ts | 172 ++ src/server/lib/lighthousePayload.ts | 123 ++ .../lib/lighthouseStoredPayload.test.ts | 159 ++ src/server/lib/lighthouseStoredPayload.ts | 310 ++++ src/server/workflows/SiteAuditWorkflow.ts | 6 +- .../workflows/site-audit-workflow-helpers.ts | 60 +- .../workflows/siteAuditWorkflowCrawl.ts | 6 +- .../workflows/siteAuditWorkflowPhases.ts | 180 +- src/serverFunctions/audit.ts | 21 +- src/serverFunctions/lighthouse.ts | 88 + src/serverFunctions/projects.ts | 11 +- src/serverFunctions/psi.ts | 66 - src/shared/lighthouse.ts | 14 + src/types/schemas/audit.ts | 3 +- src/types/schemas/lighthouse.ts | 22 + src/types/schemas/psi.ts | 37 - 59 files changed, 5726 insertions(+), 1858 deletions(-) create mode 100644 drizzle/0005_low_red_hulk.sql create mode 100644 drizzle/0006_magical_alex_wilder.sql create mode 100644 drizzle/meta/0005_snapshot.json create mode 100644 drizzle/meta/0006_snapshot.json delete mode 100644 src/client/features/audit/launch/SettingsModal.tsx create mode 100644 src/client/features/lighthouse/issues/LighthouseIssueRow.tsx rename src/client/features/{psi/issues/PsiIssuesParts.tsx => lighthouse/issues/LighthouseIssuesParts.tsx} (66%) rename src/client/features/{psi/issues/PsiIssuesScreen.tsx => lighthouse/issues/LighthouseIssuesScreen.tsx} (61%) create mode 100644 src/client/features/lighthouse/issues/LighthouseIssuesSummary.tsx create mode 100644 src/client/features/lighthouse/issues/types.ts create mode 100644 src/client/features/lighthouse/issues/utils.tsx delete mode 100644 src/client/features/psi/issues/types.ts delete mode 100644 src/client/features/psi/issues/utils.tsx create mode 100644 src/server/features/lighthouse/services/lighthouse-export.test.ts delete mode 100644 src/server/features/psi/services/PsiAuditService.ts delete mode 100644 src/server/features/psi/services/PsiIssuesService.ts delete mode 100644 src/server/features/psi/services/psi-export.ts create mode 100644 src/server/lib/audit/lighthouse.ts delete mode 100644 src/server/lib/audit/psi.ts create mode 100644 src/server/lib/dataforseoLighthouse.ts create mode 100644 src/server/lib/dataforseoLighthousePayload.test.ts create mode 100644 src/server/lib/dataforseoLighthousePayload.ts create mode 100644 src/server/lib/lighthousePayload.ts create mode 100644 src/server/lib/lighthouseStoredPayload.test.ts create mode 100644 src/server/lib/lighthouseStoredPayload.ts create mode 100644 src/serverFunctions/lighthouse.ts delete mode 100644 src/serverFunctions/psi.ts create mode 100644 src/shared/lighthouse.ts create mode 100644 src/types/schemas/lighthouse.ts delete mode 100644 src/types/schemas/psi.ts diff --git a/.gitignore b/.gitignore index 7033a06..9d2eaf4 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,6 @@ dist/ # Localflare generated files .localflare/ + +# Local Claude config +.claude/ diff --git a/README.md b/README.md index f13ecde..3af1dac 100644 --- a/README.md +++ b/README.md @@ -243,7 +243,7 @@ That means you can try OpenSEO for free with the starter credit, then decide if/ - DataForSEO Labs pricing: https://dataforseo.com/pricing/dataforseo-labs/dataforseo-google-api - DataForSEO Backlinks pricing: https://dataforseo.com/pricing/backlinks/backlinks -- Google PageSpeed Insights API docs: https://developers.google.com/speed/docs/insights/v5/get-started +- DataForSEO Lighthouse API docs: https://docs.dataforseo.com/v3/on_page/lighthouse/overview/ ### 1) Site audit diff --git a/drizzle/0005_low_red_hulk.sql b/drizzle/0005_low_red_hulk.sql new file mode 100644 index 0000000..9a1873e --- /dev/null +++ b/drizzle/0005_low_red_hulk.sql @@ -0,0 +1,30 @@ +ALTER TABLE `audit_psi_results` RENAME TO `audit_lighthouse_results`;--> statement-breakpoint +ALTER TABLE `audits` RENAME COLUMN "psi_total" TO "lighthouse_total";--> statement-breakpoint +ALTER TABLE `audits` RENAME COLUMN "psi_completed" TO "lighthouse_completed";--> statement-breakpoint +ALTER TABLE `audits` RENAME COLUMN "psi_failed" TO "lighthouse_failed";--> statement-breakpoint +PRAGMA foreign_keys=OFF;--> statement-breakpoint +CREATE TABLE `__new_audit_lighthouse_results` ( + `id` text PRIMARY KEY NOT NULL, + `audit_id` text NOT NULL, + `page_id` text NOT NULL, + `strategy` text NOT NULL, + `performance_score` integer, + `accessibility_score` integer, + `best_practices_score` integer, + `seo_score` integer, + `lcp_ms` real, + `cls` real, + `inp_ms` real, + `ttfb_ms` real, + `error_message` text, + `r2_key` text, + `payload_size_bytes` integer, + FOREIGN KEY (`audit_id`) REFERENCES `audits`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`page_id`) REFERENCES `audit_pages`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +INSERT INTO `__new_audit_lighthouse_results`("id", "audit_id", "page_id", "strategy", "performance_score", "accessibility_score", "best_practices_score", "seo_score", "lcp_ms", "cls", "inp_ms", "ttfb_ms", "error_message", "r2_key", "payload_size_bytes") SELECT "id", "audit_id", "page_id", "strategy", "performance_score", "accessibility_score", "best_practices_score", "seo_score", "lcp_ms", "cls", "inp_ms", "ttfb_ms", "error_message", "r2_key", "payload_size_bytes" FROM `audit_lighthouse_results`;--> statement-breakpoint +DROP TABLE `audit_lighthouse_results`;--> statement-breakpoint +ALTER TABLE `__new_audit_lighthouse_results` RENAME TO `audit_lighthouse_results`;--> statement-breakpoint +PRAGMA foreign_keys=ON;--> statement-breakpoint +CREATE INDEX `audit_lighthouse_results_audit_id_idx` ON `audit_lighthouse_results` (`audit_id`); \ No newline at end of file diff --git a/drizzle/0006_magical_alex_wilder.sql b/drizzle/0006_magical_alex_wilder.sql new file mode 100644 index 0000000..2f5abc4 --- /dev/null +++ b/drizzle/0006_magical_alex_wilder.sql @@ -0,0 +1 @@ +ALTER TABLE `projects` DROP COLUMN `pagespeed_api_key`; \ No newline at end of file diff --git a/drizzle/meta/0005_snapshot.json b/drizzle/meta/0005_snapshot.json new file mode 100644 index 0000000..836cc32 --- /dev/null +++ b/drizzle/meta/0005_snapshot.json @@ -0,0 +1,1526 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "2e87337a-3ed8-4d7f-ada3-e308b78249b8", + "prevId": "5faf81e6-c97c-4fe8-9d9d-b654c6dfe9de", + "tables": { + "audit_lighthouse_results": { + "name": "audit_lighthouse_results", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "audit_id": { + "name": "audit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "page_id": { + "name": "page_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "strategy": { + "name": "strategy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "performance_score": { + "name": "performance_score", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "accessibility_score": { + "name": "accessibility_score", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "best_practices_score": { + "name": "best_practices_score", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "seo_score": { + "name": "seo_score", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lcp_ms": { + "name": "lcp_ms", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cls": { + "name": "cls", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inp_ms": { + "name": "inp_ms", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ttfb_ms": { + "name": "ttfb_ms", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "r2_key": { + "name": "r2_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload_size_bytes": { + "name": "payload_size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "audit_lighthouse_results_audit_id_idx": { + "name": "audit_lighthouse_results_audit_id_idx", + "columns": [ + "audit_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "audit_lighthouse_results_audit_id_audits_id_fk": { + "name": "audit_lighthouse_results_audit_id_audits_id_fk", + "tableFrom": "audit_lighthouse_results", + "tableTo": "audits", + "columnsFrom": [ + "audit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audit_lighthouse_results_page_id_audit_pages_id_fk": { + "name": "audit_lighthouse_results_page_id_audit_pages_id_fk", + "tableFrom": "audit_lighthouse_results", + "tableTo": "audit_pages", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audit_pages": { + "name": "audit_pages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "audit_id": { + "name": "audit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "redirect_url": { + "name": "redirect_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "meta_description": { + "name": "meta_description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "canonical_url": { + "name": "canonical_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "robots_meta": { + "name": "robots_meta", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "og_title": { + "name": "og_title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "og_description": { + "name": "og_description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "og_image": { + "name": "og_image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "h1_count": { + "name": "h1_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "h2_count": { + "name": "h2_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "h3_count": { + "name": "h3_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "h4_count": { + "name": "h4_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "h5_count": { + "name": "h5_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "h6_count": { + "name": "h6_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "heading_order_json": { + "name": "heading_order_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "word_count": { + "name": "word_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "images_total": { + "name": "images_total", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "images_missing_alt": { + "name": "images_missing_alt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "images_json": { + "name": "images_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "internal_link_count": { + "name": "internal_link_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "external_link_count": { + "name": "external_link_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "has_structured_data": { + "name": "has_structured_data", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "hreflang_tags_json": { + "name": "hreflang_tags_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_indexable": { + "name": "is_indexable", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "response_time_ms": { + "name": "response_time_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "audit_pages_audit_id_idx": { + "name": "audit_pages_audit_id_idx", + "columns": [ + "audit_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "audit_pages_audit_id_audits_id_fk": { + "name": "audit_pages_audit_id_audits_id_fk", + "tableFrom": "audit_pages", + "tableTo": "audits", + "columnsFrom": [ + "audit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audits": { + "name": "audits", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_by_user_id": { + "name": "started_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "start_url": { + "name": "start_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'running'" + }, + "workflow_instance_id": { + "name": "workflow_instance_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "pages_crawled": { + "name": "pages_crawled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "pages_total": { + "name": "pages_total", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "lighthouse_total": { + "name": "lighthouse_total", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "lighthouse_completed": { + "name": "lighthouse_completed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "lighthouse_failed": { + "name": "lighthouse_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "current_phase": { + "name": "current_phase", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'discovery'" + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "audits_project_id_idx": { + "name": "audits_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "audits_started_by_user_id_idx": { + "name": "audits_started_by_user_id_idx", + "columns": [ + "started_by_user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "audits_project_id_projects_id_fk": { + "name": "audits_project_id_projects_id_fk", + "tableFrom": "audits", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "delegated_users": { + "name": "delegated_users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "delegated_users_email_unique": { + "name": "delegated_users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "keyword_metrics": { + "name": "keyword_metrics", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyword": { + "name": "keyword", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "location_code": { + "name": "location_code", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "language_code": { + "name": "language_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'en'" + }, + "search_volume": { + "name": "search_volume", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cpc": { + "name": "cpc", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "competition": { + "name": "competition", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "keyword_difficulty": { + "name": "keyword_difficulty", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "intent": { + "name": "intent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "monthly_searches": { + "name": "monthly_searches", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fetched_at": { + "name": "fetched_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "keyword_metrics_unique_project_keyword_location_language": { + "name": "keyword_metrics_unique_project_keyword_location_language", + "columns": [ + "project_id", + "keyword", + "location_code", + "language_code" + ], + "isUnique": true + }, + "keyword_metrics_lookup_idx": { + "name": "keyword_metrics_lookup_idx", + "columns": [ + "project_id", + "keyword", + "location_code", + "language_code", + "fetched_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "keyword_metrics_project_id_projects_id_fk": { + "name": "keyword_metrics_project_id_projects_id_fk", + "tableFrom": "keyword_metrics", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pagespeed_api_key": { + "name": "pagespeed_api_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": { + "projects_organization_id_organization_id_fk": { + "name": "projects_organization_id_organization_id_fk", + "tableFrom": "projects", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "saved_keywords": { + "name": "saved_keywords", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyword": { + "name": "keyword", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "location_code": { + "name": "location_code", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 2840 + }, + "language_code": { + "name": "language_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'en'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "saved_keywords_unique_project_keyword_location_language": { + "name": "saved_keywords_unique_project_keyword_location_language", + "columns": [ + "project_id", + "keyword", + "location_code", + "language_code" + ], + "isUnique": true + }, + "saved_keywords_project_created_idx": { + "name": "saved_keywords_project_created_idx", + "columns": [ + "project_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "saved_keywords_project_id_projects_id_fk": { + "name": "saved_keywords_project_id_projects_id_fk", + "tableFrom": "saved_keywords", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invitation": { + "name": "invitation", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "invitation_organizationId_idx": { + "name": "invitation_organizationId_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + "email" + ], + "isUnique": false + } + }, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "member": { + "name": "member", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "member_organizationId_idx": { + "name": "member_organizationId_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "member_userId_idx": { + "name": "member_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organization": { + "name": "organization", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "organization_slug_uidx": { + "name": "organization_slug_uidx", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + "identifier" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": { + "\"audit_psi_results\"": "\"audit_lighthouse_results\"" + }, + "columns": { + "\"audits\".\"psi_total\"": "\"audits\".\"lighthouse_total\"", + "\"audits\".\"psi_completed\"": "\"audits\".\"lighthouse_completed\"", + "\"audits\".\"psi_failed\"": "\"audits\".\"lighthouse_failed\"" + } + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0006_snapshot.json b/drizzle/meta/0006_snapshot.json new file mode 100644 index 0000000..7ce91ff --- /dev/null +++ b/drizzle/meta/0006_snapshot.json @@ -0,0 +1,1513 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "8c42b2c6-ff48-439b-9a32-aab0366ae6ff", + "prevId": "2e87337a-3ed8-4d7f-ada3-e308b78249b8", + "tables": { + "audit_lighthouse_results": { + "name": "audit_lighthouse_results", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "audit_id": { + "name": "audit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "page_id": { + "name": "page_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "strategy": { + "name": "strategy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "performance_score": { + "name": "performance_score", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "accessibility_score": { + "name": "accessibility_score", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "best_practices_score": { + "name": "best_practices_score", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "seo_score": { + "name": "seo_score", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lcp_ms": { + "name": "lcp_ms", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cls": { + "name": "cls", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inp_ms": { + "name": "inp_ms", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ttfb_ms": { + "name": "ttfb_ms", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "r2_key": { + "name": "r2_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload_size_bytes": { + "name": "payload_size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "audit_lighthouse_results_audit_id_idx": { + "name": "audit_lighthouse_results_audit_id_idx", + "columns": [ + "audit_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "audit_lighthouse_results_audit_id_audits_id_fk": { + "name": "audit_lighthouse_results_audit_id_audits_id_fk", + "tableFrom": "audit_lighthouse_results", + "tableTo": "audits", + "columnsFrom": [ + "audit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audit_lighthouse_results_page_id_audit_pages_id_fk": { + "name": "audit_lighthouse_results_page_id_audit_pages_id_fk", + "tableFrom": "audit_lighthouse_results", + "tableTo": "audit_pages", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audit_pages": { + "name": "audit_pages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "audit_id": { + "name": "audit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "redirect_url": { + "name": "redirect_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "meta_description": { + "name": "meta_description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "canonical_url": { + "name": "canonical_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "robots_meta": { + "name": "robots_meta", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "og_title": { + "name": "og_title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "og_description": { + "name": "og_description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "og_image": { + "name": "og_image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "h1_count": { + "name": "h1_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "h2_count": { + "name": "h2_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "h3_count": { + "name": "h3_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "h4_count": { + "name": "h4_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "h5_count": { + "name": "h5_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "h6_count": { + "name": "h6_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "heading_order_json": { + "name": "heading_order_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "word_count": { + "name": "word_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "images_total": { + "name": "images_total", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "images_missing_alt": { + "name": "images_missing_alt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "images_json": { + "name": "images_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "internal_link_count": { + "name": "internal_link_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "external_link_count": { + "name": "external_link_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "has_structured_data": { + "name": "has_structured_data", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "hreflang_tags_json": { + "name": "hreflang_tags_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_indexable": { + "name": "is_indexable", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "response_time_ms": { + "name": "response_time_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "audit_pages_audit_id_idx": { + "name": "audit_pages_audit_id_idx", + "columns": [ + "audit_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "audit_pages_audit_id_audits_id_fk": { + "name": "audit_pages_audit_id_audits_id_fk", + "tableFrom": "audit_pages", + "tableTo": "audits", + "columnsFrom": [ + "audit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audits": { + "name": "audits", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_by_user_id": { + "name": "started_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "start_url": { + "name": "start_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'running'" + }, + "workflow_instance_id": { + "name": "workflow_instance_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "pages_crawled": { + "name": "pages_crawled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "pages_total": { + "name": "pages_total", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "lighthouse_total": { + "name": "lighthouse_total", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "lighthouse_completed": { + "name": "lighthouse_completed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "lighthouse_failed": { + "name": "lighthouse_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "current_phase": { + "name": "current_phase", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'discovery'" + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "audits_project_id_idx": { + "name": "audits_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "audits_started_by_user_id_idx": { + "name": "audits_started_by_user_id_idx", + "columns": [ + "started_by_user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "audits_project_id_projects_id_fk": { + "name": "audits_project_id_projects_id_fk", + "tableFrom": "audits", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "delegated_users": { + "name": "delegated_users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "delegated_users_email_unique": { + "name": "delegated_users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "keyword_metrics": { + "name": "keyword_metrics", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyword": { + "name": "keyword", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "location_code": { + "name": "location_code", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "language_code": { + "name": "language_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'en'" + }, + "search_volume": { + "name": "search_volume", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cpc": { + "name": "cpc", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "competition": { + "name": "competition", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "keyword_difficulty": { + "name": "keyword_difficulty", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "intent": { + "name": "intent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "monthly_searches": { + "name": "monthly_searches", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fetched_at": { + "name": "fetched_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "keyword_metrics_unique_project_keyword_location_language": { + "name": "keyword_metrics_unique_project_keyword_location_language", + "columns": [ + "project_id", + "keyword", + "location_code", + "language_code" + ], + "isUnique": true + }, + "keyword_metrics_lookup_idx": { + "name": "keyword_metrics_lookup_idx", + "columns": [ + "project_id", + "keyword", + "location_code", + "language_code", + "fetched_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "keyword_metrics_project_id_projects_id_fk": { + "name": "keyword_metrics_project_id_projects_id_fk", + "tableFrom": "keyword_metrics", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": { + "projects_organization_id_organization_id_fk": { + "name": "projects_organization_id_organization_id_fk", + "tableFrom": "projects", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "saved_keywords": { + "name": "saved_keywords", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyword": { + "name": "keyword", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "location_code": { + "name": "location_code", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 2840 + }, + "language_code": { + "name": "language_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'en'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "saved_keywords_unique_project_keyword_location_language": { + "name": "saved_keywords_unique_project_keyword_location_language", + "columns": [ + "project_id", + "keyword", + "location_code", + "language_code" + ], + "isUnique": true + }, + "saved_keywords_project_created_idx": { + "name": "saved_keywords_project_created_idx", + "columns": [ + "project_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "saved_keywords_project_id_projects_id_fk": { + "name": "saved_keywords_project_id_projects_id_fk", + "tableFrom": "saved_keywords", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invitation": { + "name": "invitation", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "invitation_organizationId_idx": { + "name": "invitation_organizationId_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + "email" + ], + "isUnique": false + } + }, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "member": { + "name": "member", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "member_organizationId_idx": { + "name": "member_organizationId_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "member_userId_idx": { + "name": "member_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organization": { + "name": "organization", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "organization_slug_uidx": { + "name": "organization_slug_uidx", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + "identifier" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 4474950..8528c0f 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -36,6 +36,20 @@ "when": 1773935379368, "tag": "0004_faithful_sunset_bain", "breakpoints": true + }, + { + "idx": 5, + "version": "6", + "when": 1773965298920, + "tag": "0005_low_red_hulk", + "breakpoints": true + }, + { + "idx": 6, + "version": "6", + "when": 1774320825595, + "tag": "0006_magical_alex_wilder", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/client/features/audit/launch/AuditHistorySection.tsx b/src/client/features/audit/launch/AuditHistorySection.tsx index 9d882cc..b4d9711 100644 --- a/src/client/features/audit/launch/AuditHistorySection.tsx +++ b/src/client/features/audit/launch/AuditHistorySection.tsx @@ -38,7 +38,7 @@ export function AuditHistorySection({ URL Status Pages - PSI + Lighthouse @@ -54,7 +54,7 @@ export function AuditHistorySection({ {audit.pagesTotal || audit.pagesCrawled} - {audit.ranPsi ? ( + {audit.ranLighthouse ? ( Yes ) : null} diff --git a/src/client/features/audit/launch/LaunchFormCard.tsx b/src/client/features/audit/launch/LaunchFormCard.tsx index e674939..3ac3a65 100644 --- a/src/client/features/audit/launch/LaunchFormCard.tsx +++ b/src/client/features/audit/launch/LaunchFormCard.tsx @@ -1,48 +1,33 @@ import type { FormEvent } from "react"; -import { Loader2, Settings } from "lucide-react"; +import { Loader2 } from "lucide-react"; import { MAX_PAGES_LIMIT, MIN_PAGES, type LaunchFormApi, type LaunchState, - type SettingsFormApi, } from "@/client/features/audit/launch/types"; export function LaunchFormCard({ launchForm, - settingsForm, state, setState, isPending, onSubmit, - onOpenSettings, - onRunPsiToggle, + onRunLighthouseToggle, commitMaxPagesInput, }: { launchForm: LaunchFormApi; - settingsForm: SettingsFormApi; state: LaunchState; setState: React.Dispatch>; isPending: boolean; onSubmit: (event: FormEvent) => void; - onOpenSettings: () => void; - onRunPsiToggle: (checked: boolean) => void; + onRunLighthouseToggle: (checked: boolean) => void; commitMaxPagesInput: () => number; }) { return (
-
-

Start New Audit

- -
+

Start New Audit

-
@@ -138,42 +122,42 @@ function LaunchOptions({ ); } -function PsiOptions({ +function LighthouseOptions({ launchForm, - settingsForm, - onRunPsiToggle, + onRunLighthouseToggle, }: { launchForm: LaunchFormApi; - settingsForm: SettingsFormApi; - onRunPsiToggle: (checked: boolean) => void; + onRunLighthouseToggle: (checked: boolean) => void; }) { return (
- snapshot.values.runPsi}> - {(runPsi) => - runPsi ? ( + snapshot.values.runLighthouse} + > + {(runLighthouse) => + runLighthouse ? (
- PSI mode - + Audit scope + {(field) => ( { - field.handleChange(event.target.value); - if (state.settingsError || state.psiRequirementError) { - setState((prev) => ({ - ...prev, - settingsError: null, - psiRequirementError: null, - })); - } - }} - /> - )} - - -
-

- Stored on this project and reused by PSI and Site Audit. Required - to run PSI checks in audits. -

- - {state.settingsError ? ( -

{state.settingsError}

- ) : null} -
- -
- - -
-
- - - ); -} - -function PsiKeyHelp() { - return ( -
-

Need a PSI key?

-
    -
  1. - Open{" "} - - PageSpeed Insights getting started - {" "} - and click "Get a key". -
  2. -
  3. Create any Google Cloud project (for example: Open SEO).
  4. -
  5. Paste the key here and save.
  6. -
-
- ); -} diff --git a/src/client/features/audit/launch/types.ts b/src/client/features/audit/launch/types.ts index 1c198d3..110db51 100644 --- a/src/client/features/audit/launch/types.ts +++ b/src/client/features/audit/launch/types.ts @@ -1,12 +1,8 @@ import { useForm } from "@tanstack/react-form"; export type LaunchState = { - isSettingsOpen: boolean; - showPsiKey: boolean; urlError: string | null; - psiRequirementError: string | null; startError: string | null; - settingsError: string | null; }; export const MIN_PAGES = 10; @@ -17,15 +13,10 @@ export function useLaunchForm() { defaultValues: { url: "", maxPagesInput: "50", - runPsi: false, - psiMode: "auto" as "auto" | "all", + runLighthouse: false, + lighthouseMode: "auto" as "auto" | "all", }, }); } -export function useSettingsForm() { - return useForm({ defaultValues: { psiApiKey: "" } }); -} - export type LaunchFormApi = ReturnType; -export type SettingsFormApi = ReturnType; diff --git a/src/client/features/audit/launch/useLaunchController.ts b/src/client/features/audit/launch/useLaunchController.ts index 879a9e1..37ef45a 100644 --- a/src/client/features/audit/launch/useLaunchController.ts +++ b/src/client/features/audit/launch/useLaunchController.ts @@ -1,4 +1,4 @@ -import { useEffect, useState, type FormEvent } from "react"; +import { useState, type FormEvent } from "react"; import { useMutation, useQuery } from "@tanstack/react-query"; import { toast } from "sonner"; import { @@ -6,16 +6,10 @@ import { getAuditHistory, startAudit, } from "@/serverFunctions/audit"; -import { - clearProjectPsiApiKey, - getProjectPsiApiKey, - saveProjectPsiApiKey, -} from "@/serverFunctions/psi"; import { MAX_PAGES_LIMIT, MIN_PAGES, useLaunchForm, - useSettingsForm, type LaunchState, } from "@/client/features/audit/launch/types"; import { getStandardErrorMessage } from "@/client/lib/error-messages"; @@ -28,33 +22,19 @@ export function useLaunchController({ onAuditStarted: (auditId: string) => void; }) { const launchForm = useLaunchForm(); - const settingsForm = useSettingsForm(); const [state, setState] = useState({ - isSettingsOpen: false, - showPsiKey: false, urlError: null, - psiRequirementError: null, startError: null, - settingsError: null, }); const historyQuery = useQuery({ queryKey: ["audit-history", projectId], queryFn: () => getAuditHistory({ data: { projectId } }), }); - const keyQuery = useQuery({ - queryKey: ["projectPsiApiKey", projectId], - queryFn: () => getProjectPsiApiKey({ data: { projectId } }), + const { startMutation, deleteMutation } = useLaunchMutations({ + projectId, + historyRefetch: historyQuery.refetch, }); - const { startMutation, deleteMutation, saveKeyMutation, clearKeyMutation } = - useLaunchMutations({ - projectId, - historyRefetch: historyQuery.refetch, - keyRefetch: keyQuery.refetch, - clearPsiApiKeyField: () => settingsForm.setFieldValue("psiApiKey", ""), - }); - - useSyncPsiKeyField(keyQuery.data?.apiKey, settingsForm); const applyMaxPages = (value: number) => { const safeValue = Number.isFinite(value) @@ -72,20 +52,13 @@ export function useLaunchController({ const handleStart = () => { const launchValues = launchForm.state.values; - const settingsValues = settingsForm.state.values; const effectiveMaxPages = commitMaxPagesInput(); setState((prev) => ({ ...prev, startError: null })); - if (!launchValues.url.trim()) + if (!launchValues.url.trim()) { return setState((prev) => ({ ...prev, urlError: "Please enter a URL." })); - if (launchValues.runPsi && !settingsValues.psiApiKey.trim()) { - return setState((prev) => ({ - ...prev, - psiRequirementError: - "Set a Google PageSpeed Insights API key before running PSI checks.", - isSettingsOpen: true, - })); } + if (effectiveMaxPages > 500) { const confirmed = window.confirm( `You are about to crawl ${effectiveMaxPages.toLocaleString()} pages. This is okay, but it may take a while. Continue?`, @@ -98,19 +71,13 @@ export function useLaunchController({ projectId, startUrl: launchValues.url, maxPages: effectiveMaxPages, - psiStrategy: launchValues.runPsi ? launchValues.psiMode : "none", - psiApiKey: launchValues.runPsi - ? settingsValues.psiApiKey || undefined - : undefined, + lighthouseStrategy: launchValues.runLighthouse + ? launchValues.lighthouseMode + : "none", }, { onSuccess: (result) => { - setState((prev) => ({ - ...prev, - urlError: null, - psiRequirementError: null, - startError: null, - })); + setState({ urlError: null, startError: null }); toast.success("Audit started!"); onAuditStarted(result.auditId); }, @@ -126,7 +93,6 @@ export function useLaunchController({ return { launchForm, - settingsForm, state, setState, historyQuery, @@ -136,45 +102,25 @@ export function useLaunchController({ event.preventDefault(); handleStart(); }, - openSettings: () => setState((prev) => ({ ...prev, isSettingsOpen: true })), - onRunPsiToggle: (checked: boolean) => - handleRunPsiToggle(checked, launchForm, settingsForm, setState), - saveSettings: () => - handleSaveSettings(settingsForm, setState, saveKeyMutation.mutate), - clearPsiKey: () => clearKeyMutation.mutate(), + onRunLighthouseToggle: (checked: boolean) => + handleRunLighthouseToggle(checked, launchForm), deleteAudit: (auditId: string) => deleteMutation.mutate(auditId), }; } -function useSyncPsiKeyField( - apiKey: string | null | undefined, - settingsForm: ReturnType, -) { - useEffect(() => { - if (apiKey) { - settingsForm.setFieldValue("psiApiKey", apiKey); - } - }, [apiKey, settingsForm]); -} - function useLaunchMutations({ projectId, historyRefetch, - keyRefetch, - clearPsiApiKeyField, }: { projectId: string; historyRefetch: () => Promise; - keyRefetch: () => Promise; - clearPsiApiKeyField: () => void; }) { const startMutation = useMutation({ mutationFn: (data: { projectId: string; startUrl: string; maxPages: number; - psiStrategy: "auto" | "all" | "none"; - psiApiKey?: string; + lighthouseStrategy: "auto" | "all" | "none"; }) => startAudit({ data }), }); @@ -187,67 +133,12 @@ function useLaunchMutations({ }, }); - const saveKeyMutation = useMutation({ - mutationFn: (apiKey: string) => - saveProjectPsiApiKey({ data: { projectId, apiKey } }), - onSuccess: async () => { - toast.success("PSI API key saved for this project"); - await keyRefetch(); - }, - }); - - const clearKeyMutation = useMutation({ - mutationFn: () => clearProjectPsiApiKey({ data: { projectId } }), - onSuccess: async () => { - clearPsiApiKeyField(); - toast.success("PSI API key cleared"); - await keyRefetch(); - }, - }); - - return { startMutation, deleteMutation, saveKeyMutation, clearKeyMutation }; + return { startMutation, deleteMutation }; } -function handleRunPsiToggle( +function handleRunLighthouseToggle( checked: boolean, launchForm: ReturnType, - settingsForm: ReturnType, - setState: React.Dispatch>, ) { - if (!checked) { - setState((prev) => ({ ...prev, psiRequirementError: null })); - launchForm.setFieldValue("runPsi", false); - return; - } - - if (!settingsForm.state.values.psiApiKey.trim()) { - setState((prev) => ({ ...prev, isSettingsOpen: true })); - return; - } - - launchForm.setFieldValue("runPsi", true); -} - -function handleSaveSettings( - settingsForm: ReturnType, - setState: React.Dispatch>, - save: (apiKey: string) => void, -) { - const trimmed = settingsForm.state.values.psiApiKey.trim(); - if (!trimmed) { - setState((prev) => ({ - ...prev, - settingsError: "Please enter an API key.", - })); - return; - } - - setState((prev) => ({ - ...prev, - settingsError: null, - psiRequirementError: null, - showPsiKey: false, - isSettingsOpen: false, - })); - save(trimmed); + launchForm.setFieldValue("runLighthouse", checked); } diff --git a/src/client/features/audit/results/ResultsTables.tsx b/src/client/features/audit/results/ResultsTables.tsx index 578a638..2ea5c4f 100644 --- a/src/client/features/audit/results/ResultsTables.tsx +++ b/src/client/features/audit/results/ResultsTables.tsx @@ -2,10 +2,35 @@ import { ChevronDown, Download, ExternalLink } from "lucide-react"; import { extractPathname, HttpStatusBadge, - PsiScoreBadge, + LighthouseScoreBadge, } from "@/client/features/audit/shared"; import type { AuditResultsData } from "@/client/features/audit/results/types"; +type LighthouseFailureFields = { + errorMessage: string | null; + performanceScore: number | null; + accessibilityScore: number | null; + bestPracticesScore: number | null; + seoScore: number | null; +}; + +function hasMissingLighthouseScores(row: LighthouseFailureFields) { + return ( + row.performanceScore == null && + row.accessibilityScore == null && + row.bestPracticesScore == null && + row.seoScore == null + ); +} + +export function isLighthouseFailure(row: LighthouseFailureFields) { + return !!row.errorMessage || hasMissingLighthouseScores(row); +} + +function getLighthouseFailureMessage(row: LighthouseFailureFields) { + return row.errorMessage ?? "Lighthouse returned no category scores"; +} + export function PagesTable({ pages }: { pages: AuditResultsData["pages"] }) { return (
@@ -22,7 +47,7 @@ export function PagesTable({ pages }: { pages: AuditResultsData["pages"] }) { - {pages.map((page) => ( + {pages.map((page: AuditResultsData["pages"][number]) => ( - {psi.map((result) => ( + {lighthouse.map((result: AuditResultsData["lighthouse"][number]) => ( candidate.id === result.pageId)} + page={pages.find( + (candidate: AuditResultsData["pages"][number]) => + candidate.id === result.pageId, + )} /> ))} @@ -108,15 +139,18 @@ export function PerformanceTable({ } function PerformanceRow({ + auditId, projectId, result, page, }: { + auditId: string; projectId: string; - result: AuditResultsData["psi"][number]; + result: AuditResultsData["lighthouse"][number]; page: AuditResultsData["pages"][number] | undefined; }) { - const isFailed = !!result.errorMessage; + const isFailed = isLighthouseFailure(result); + const failureMessage = getLighthouseFailureMessage(result); return ( @@ -128,7 +162,7 @@ function PerformanceRow({ {isFailed ? ( failed @@ -137,13 +171,13 @@ function PerformanceRow({ )} - + - + - + {result.lcpMs ? `${(result.lcpMs / 1000).toFixed(1)}s` : "-"} @@ -158,10 +192,10 @@ function PerformanceRow({ {result.ttfbMs ? `${Math.round(result.ttfbMs)}ms` : "-"} - {result.r2Key ? ( + {result.r2Key && !isFailed ? ( View issues diff --git a/src/client/features/audit/results/ResultsView.tsx b/src/client/features/audit/results/ResultsView.tsx index 71b5822..f1340aa 100644 --- a/src/client/features/audit/results/ResultsView.tsx +++ b/src/client/features/audit/results/ResultsView.tsx @@ -7,6 +7,7 @@ import { import type { AuditResultsData } from "@/client/features/audit/results/types"; import { ExportDropdown, + isLighthouseFailure, PagesTable, PerformanceTable, } from "@/client/features/audit/results/ResultsTables"; @@ -24,32 +25,32 @@ export function ResultsView({ tab: string; setSearchParams: SearchSetter; }) { - const { audit, pages, psi } = data; - const hasPerformanceTab = psi.length > 0; + const { audit, pages, lighthouse } = data; + const hasPerformanceTab = lighthouse.length > 0; const activeTab = hasPerformanceTab ? tab : "pages"; - const stats = useResultStats(pages, psi); + const stats = useResultStats(pages, lighthouse); return ( <>
{ if (activeTab === "performance") { - exportPerformance(psi, pages, format); + exportPerformance(lighthouse, pages, format); return; } exportPages(pages, format); @@ -57,8 +58,13 @@ export function ResultsView({ /> {activeTab === "pages" && } - {activeTab === "performance" && psi.length > 0 && ( - + {activeTab === "performance" && lighthouse.length > 0 && ( + )}
@@ -68,28 +74,34 @@ export function ResultsView({ function useResultStats( pages: AuditResultsData["pages"], - psi: AuditResultsData["psi"], + lighthouse: AuditResultsData["lighthouse"], ) { const averageResponseMs = useMemo(() => { if (pages.length === 0) return 0; const total = pages.reduce( - (sum, page) => sum + (page.responseTimeMs ?? 0), + (sum: number, page: AuditResultsData["pages"][number]) => + sum + (page.responseTimeMs ?? 0), 0, ); return Math.round(total / pages.length); }, [pages]); - const psiSummary = useMemo(() => { - const failed = psi.filter((row) => !!row.errorMessage).length; - const successful = psi.filter((row) => !row.errorMessage); + const lighthouseSummary = useMemo(() => { + const failed = lighthouse.filter( + (row: AuditResultsData["lighthouse"][number]) => isLighthouseFailure(row), + ).length; + const successful = lighthouse.filter( + (row: AuditResultsData["lighthouse"][number]) => + !isLighthouseFailure(row), + ); const averageScore = ( key: "performanceScore" | "seoScore" | "accessibilityScore", ) => { const values = successful - .map((row) => row[key]) - .filter((value): value is number => value != null); + .map((row: AuditResultsData["lighthouse"][number]) => row[key]) + .filter((value: number | null): value is number => value != null); if (values.length === 0) return null; - const total = values.reduce((sum, value) => sum + value, 0); + const total = values.reduce((sum: number, value) => sum + value, 0); return Math.round(total / values.length); }; @@ -99,21 +111,21 @@ function useResultStats( avgSeo: averageScore("seoScore"), avgAccessibility: averageScore("accessibilityScore"), }; - }, [psi]); + }, [lighthouse]); - return { averageResponseMs, psiSummary }; + return { averageResponseMs, lighthouseSummary }; } function ResultsHeader({ pageCount, - psiCount, + lighthouseCount, hasPerformanceTab, activeTab, setSearchParams, onExport, }: { pageCount: number; - psiCount: number; + lighthouseCount: number; hasPerformanceTab: boolean; activeTab: string; setSearchParams: SearchSetter; @@ -135,7 +147,7 @@ function ResultsHeader({ className={`tab ${activeTab === "performance" ? "tab-active" : ""}`} onClick={() => setSearchParams({ tab: "performance" })} > - Performance ({psiCount}) + Performance ({lighthouseCount})
) : ( @@ -150,15 +162,15 @@ function ResultsHeader({ function StatsGrid({ pagesCrawled, totalPages, - totalPsi, + totalLighthouse, averageResponseMs, - psiSummary, + lighthouseSummary, }: { pagesCrawled: number; totalPages: number; - totalPsi: number; + totalLighthouse: number; averageResponseMs: number; - psiSummary: { + lighthouseSummary: { failed: number; avgPerformance: number | null; avgSeo: number | null; @@ -169,37 +181,43 @@ function StatsGrid({
- + - {totalPsi > 0 && ( + {totalLighthouse > 0 && ( <> - 0 ? "text-error" : "text-success"} + label="Avg Lighthouse A11y" + value={ + lighthouseSummary.avgAccessibility == null + ? "-" + : String(lighthouseSummary.avgAccessibility) + } + className={scoreClass(lighthouseSummary.avgAccessibility)} + /> + 0 ? "text-error" : "text-success" + } /> )} diff --git a/src/client/features/audit/results/export.ts b/src/client/features/audit/results/export.ts index c69ca1f..e3c8f24 100644 --- a/src/client/features/audit/results/export.ts +++ b/src/client/features/audit/results/export.ts @@ -15,7 +15,7 @@ export function exportPages( pages: AuditResultsData["pages"], format: "csv" | "json", ) { - const rows = pages.map((page) => ({ + const rows = pages.map((page: AuditResultsData["pages"][number]) => ({ url: page.url, statusCode: page.statusCode, title: page.title ?? "", @@ -45,7 +45,7 @@ export function exportPages( "Missing Alt", "Response Time (ms)", ]; - const lines = rows.map((row) => [ + const lines = rows.map((row: (typeof rows)[number]) => [ row.url, row.statusCode, row.title, @@ -60,24 +60,29 @@ export function exportPages( } export function exportPerformance( - psi: AuditResultsData["psi"], + lighthouse: AuditResultsData["lighthouse"], pages: AuditResultsData["pages"], format: "csv" | "json", ) { - const rows = psi.map((result) => { - const page = pages.find((candidate) => candidate.id === result.pageId); - return { - url: page?.url ?? "", - strategy: result.strategy, - performance: result.performanceScore, - accessibility: result.accessibilityScore, - seo: result.seoScore, - lcpMs: result.lcpMs, - cls: result.cls, - inpMs: result.inpMs, - ttfbMs: result.ttfbMs, - }; - }); + const rows = lighthouse.map( + (result: AuditResultsData["lighthouse"][number]) => { + const page = pages.find( + (candidate: AuditResultsData["pages"][number]) => + candidate.id === result.pageId, + ); + return { + url: page?.url ?? "", + strategy: result.strategy, + performance: result.performanceScore, + accessibility: result.accessibilityScore, + seo: result.seoScore, + lcpMs: result.lcpMs, + cls: result.cls, + inpMs: result.inpMs, + ttfbMs: result.ttfbMs, + }; + }, + ); if (format === "json") { downloadFile( @@ -99,7 +104,7 @@ export function exportPerformance( "INP (ms)", "TTFB (ms)", ]; - const lines = rows.map((row) => [ + const lines = rows.map((row: (typeof rows)[number]) => [ row.url, row.strategy, row.performance, diff --git a/src/client/features/audit/shared.tsx b/src/client/features/audit/shared.tsx index ec2fe00..1775c25 100644 --- a/src/client/features/audit/shared.tsx +++ b/src/client/features/audit/shared.tsx @@ -70,7 +70,7 @@ export function HttpStatusBadge({ code }: { code: number | null }) { return {code}; } -export function PsiScoreBadge({ score }: { score: number | null }) { +export function LighthouseScoreBadge({ score }: { score: number | null }) { if (score == null) { return -; } diff --git a/src/client/features/lighthouse/issues/LighthouseIssueRow.tsx b/src/client/features/lighthouse/issues/LighthouseIssueRow.tsx new file mode 100644 index 0000000..f7b093a --- /dev/null +++ b/src/client/features/lighthouse/issues/LighthouseIssueRow.tsx @@ -0,0 +1,164 @@ +import { useState, type ReactNode } from "react"; +import { + ChevronRight, + ExternalLink, + FileWarning, + Info, + TriangleAlert, +} from "lucide-react"; +import type { LighthouseIssue } from "./types"; + +export function LighthouseIssueRow({ issue }: { issue: LighthouseIssue }) { + const [open, setOpen] = useState(false); + const hasDetails = !!(issue.description || issue.items.length > 0); + + return ( + <> + hasDetails && setOpen(!open)} + > + + {hasDetails ? ( + + ) : null} + + + + {severityIcon(issue.severity)} + {issue.severity} + + + +
+

{issue.title}

+ {issue.displayValue ? ( +

+ {issue.displayValue} +

+ ) : null} +
+ + + {issue.category} + + + {issue.impactMs != null || issue.impactBytes != null ? ( + + {issue.impactMs ? formatMs(issue.impactMs) : null} + {issue.impactMs && issue.impactBytes ? " / " : null} + {issue.impactBytes ? formatBytes(issue.impactBytes) : null} + + ) : null} + + + {issue.score != null ? ( + + {issue.score} + + ) : null} + + + {open ? ( + + +
+ {issue.description ? ( +
+ {renderInlineMarkdown(issue.description)} +
+ ) : null} + {issue.items.length > 0 ? ( +
+ + Affected items ({issue.items.length}) + +
+ {issue.items.map((item, itemIndex) => ( +
+                        {item}
+                      
+ ))} +
+
+ ) : null} +
+ + + ) : null} + + ); +} + +function formatMs(ms: number) { + if (ms >= 1000) return `${(ms / 1000).toFixed(1)}s`; + return `${ms}ms`; +} + +function formatBytes(bytes: number) { + if (bytes === 0) return "0 B"; + if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + if (bytes >= 1024) return `${(bytes / 1024).toFixed(0)} KB`; + return `${bytes} B`; +} + +function renderInlineMarkdown(markdown: string): ReactNode { + const linkPattern = /\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g; + const nodes: ReactNode[] = []; + let cursor = 0; + let match = linkPattern.exec(markdown); + + while (match) { + const [raw, label, href] = match; + const index = match.index; + + if (index > cursor) { + nodes.push(markdown.slice(cursor, index)); + } + + nodes.push( + + {label} + + , + ); + + cursor = index + raw.length; + match = linkPattern.exec(markdown); + } + + if (cursor < markdown.length) { + nodes.push(markdown.slice(cursor)); + } + + return nodes.length ? nodes : markdown; +} + +function severityBadgeClass(severity: "critical" | "warning" | "info") { + if (severity === "critical") { + return "border-error/30 bg-error/10 text-error/80"; + } + if (severity === "warning") { + return "border-warning/35 bg-warning/10 text-warning/80"; + } + return "border-info/30 bg-info/10 text-info/80"; +} + +function severityIcon(severity: "critical" | "warning" | "info") { + if (severity === "critical") return ; + if (severity === "warning") return ; + return ; +} diff --git a/src/client/features/psi/issues/PsiIssuesParts.tsx b/src/client/features/lighthouse/issues/LighthouseIssuesParts.tsx similarity index 66% rename from src/client/features/psi/issues/PsiIssuesParts.tsx rename to src/client/features/lighthouse/issues/LighthouseIssuesParts.tsx index 75b39fd..d8c0de3 100644 --- a/src/client/features/psi/issues/PsiIssuesParts.tsx +++ b/src/client/features/lighthouse/issues/LighthouseIssuesParts.tsx @@ -6,26 +6,33 @@ import { Info, TriangleAlert, } from "lucide-react"; -import type { CategoryTab, ExportPayload, PsiIssue } from "./types"; -import { - categoryLabel, - renderInlineMarkdown, - severityBadgeClass, - severityIcon, -} from "./utils"; +import type { + CategoryTab, + ExportPayload, + LighthouseIssue, + LighthouseMetrics, + LighthouseScores, +} from "./types"; +import { LighthouseIssueRow } from "./LighthouseIssueRow"; +import { LighthouseIssuesSummary } from "./LighthouseIssuesSummary"; +import { categoryLabel } from "./utils"; import { categoryTabs } from "./types"; -export function PsiIssuesHeader({ +export function LighthouseIssuesHeader({ backLabel, onBack, scannedAt, finalUrl, + scores, + metrics, severityCounts, }: { backLabel: string; onBack: () => void; scannedAt?: string; finalUrl?: string; + scores?: LighthouseScores | null; + metrics?: LighthouseMetrics | null; severityCounts: { critical: number; warning: number; info: number }; }) { return ( @@ -44,11 +51,12 @@ export function PsiIssuesHeader({
-

PSI Issues

+

Lighthouse Issues

{finalUrl ?? "Loading URL..."}

+
@@ -69,7 +77,7 @@ export function PsiIssuesHeader({ ); } -export function PsiIssuesToolbar({ +export function LighthouseIssuesToolbar({ category, categoryCounts, selectedCategoryLabel, @@ -85,12 +93,12 @@ export function PsiIssuesToolbar({ categoryCounts: Record; selectedCategoryLabel: string; isBusy: boolean; - visibleIssues: PsiIssue[]; - allIssues: PsiIssue[]; + visibleIssues: LighthouseIssue[]; + allIssues: LighthouseIssue[]; onCategoryChange: (next: CategoryTab) => void; onCopy: (data: ExportPayload, toastMessage: string) => void; onExport: (data: ExportPayload) => void; - onExportCsv: (issues: PsiIssue[], variant: "all" | "current") => void; + onExportCsv: (issues: LighthouseIssue[], variant: "all" | "current") => void; }) { const exportCurrentCategory: ExportPayload = category === "all" ? { mode: "issues" } : { mode: "category", category }; @@ -161,14 +169,14 @@ function ExportMenu({ onExportCsv, visibleIssues, }: { - allIssues: PsiIssue[]; + allIssues: LighthouseIssue[]; categoryLabelLower: string; exportCurrentCategory: ExportPayload; isBusy: boolean; onCopy: (data: ExportPayload, toastMessage: string) => void; onExport: (data: ExportPayload) => void; - onExportCsv: (issues: PsiIssue[], variant: "all" | "current") => void; - visibleIssues: PsiIssue[]; + onExportCsv: (issues: LighthouseIssue[], variant: "all" | "current") => void; + visibleIssues: LighthouseIssue[]; }) { return (
@@ -201,21 +209,23 @@ function ExportMenu({
  • @@ -234,12 +244,12 @@ function ExportMenu({ disabled={isBusy} onClick={() => onExport({ mode: "issues" })} > - Download all issues + Download all actionable issues
  • @@ -258,7 +268,7 @@ function ExportMenu({ disabled={!allIssues.length} onClick={() => onExportCsv(allIssues, "all")} > - Download all issues + Download all actionable issues
  • @@ -266,12 +276,14 @@ function ExportMenu({ ); } -export function PsiIssueList({ +export function LighthouseIssueList({ issues, isLoading, + emptyMessage, }: { - issues: PsiIssue[]; + issues: LighthouseIssue[]; isLoading: boolean; + emptyMessage?: string; }) { if (isLoading) { return

    Loading issues...

    ; @@ -279,84 +291,40 @@ export function PsiIssueList({ if (!issues.length) { return (

    - No unresolved issues for this category. + {emptyMessage ?? "No actionable issues for this category."}

    ); } return ( -
    - {issues.map((issue) => ( - - ))} -
    - ); -} - -function PsiIssueCard({ issue }: { issue: PsiIssue }) { - return ( -
    -
    -
    -
    - {issue.category} - - {severityIcon(issue.severity)} - {issue.severity} - - {issue.score != null ? ( -
    - - Score {issue.score} - -
    - ) : null} -
    - - {issue.impactMs != null || issue.impactBytes != null ? ( - - Impact {issue.impactMs ?? 0}ms / {issue.impactBytes ?? 0} bytes - - ) : null} -
    - -

    {issue.title}

    - - {issue.displayValue ? ( -

    {issue.displayValue}

    - ) : null} - - {issue.description ? ( -
    - {renderInlineMarkdown(issue.description)} -
    - ) : null} - - {issue.items.length > 0 ? ( -
    - - Affected items ({issue.items.length}) - -
    - {issue.items.map((item) => ( -
    -                  {item}
    -                
    - ))} -
    -
    - ) : null} -
    -
    + + + + + + + + + + + + + + + + + + + + {issues.map((issue, issueIndex) => ( + + ))} + +
    + SeverityIssueCategory + Impact + Score
    ); } diff --git a/src/client/features/psi/issues/PsiIssuesScreen.tsx b/src/client/features/lighthouse/issues/LighthouseIssuesScreen.tsx similarity index 61% rename from src/client/features/psi/issues/PsiIssuesScreen.tsx rename to src/client/features/lighthouse/issues/LighthouseIssuesScreen.tsx index 416987c..1a050d9 100644 --- a/src/client/features/psi/issues/PsiIssuesScreen.tsx +++ b/src/client/features/lighthouse/issues/LighthouseIssuesScreen.tsx @@ -1,7 +1,11 @@ import { useMutation, useQuery } from "@tanstack/react-query"; +import { AlertCircle, TriangleAlert } from "lucide-react"; import { toast } from "sonner"; -import { exportAuditPsi, getAuditPsiIssues } from "@/serverFunctions/psi"; -import type { CategoryTab, ExportPayload, PsiIssue } from "./types"; +import { + exportAuditLighthouseIssues, + getAuditLighthouseIssues, +} from "@/serverFunctions/lighthouse"; +import type { CategoryTab, ExportPayload, LighthouseIssue } from "./types"; import { categoryLabel, categorySlug, @@ -9,13 +13,13 @@ import { issuesToCsv, } from "./utils"; import { - PsiIssueList, - PsiIssuesHeader, - PsiIssuesToolbar, -} from "./PsiIssuesParts"; + LighthouseIssueList, + LighthouseIssuesHeader, + LighthouseIssuesToolbar, +} from "./LighthouseIssuesParts"; import { categoryTabs } from "./types"; -type PsiIssuesScreenProps = { +type LighthouseIssuesScreenProps = { projectId: string; resultId: string; category: CategoryTab; @@ -24,26 +28,14 @@ type PsiIssuesScreenProps = { onCategoryChange: (next: CategoryTab) => void; }; -export function PsiIssuesScreen(props: PsiIssuesScreenProps) { +export function LighthouseIssuesScreen(props: LighthouseIssuesScreenProps) { const { projectId, resultId, category, backLabel, onBack, onCategoryChange } = props; const issuesQuery = useQuery({ - queryKey: ["auditPsiIssues", projectId, resultId, category], + queryKey: ["auditLighthouseIssues", projectId, resultId], queryFn: () => - getAuditPsiIssues({ - data: { - projectId, - resultId, - category: category === "all" ? undefined : category, - }, - }), - }); - - const summaryQuery = useQuery({ - queryKey: ["auditPsiIssuesSummary", projectId, resultId], - queryFn: () => - getAuditPsiIssues({ + getAuditLighthouseIssues({ data: { projectId, resultId, @@ -52,8 +44,10 @@ export function PsiIssuesScreen(props: PsiIssuesScreenProps) { }); const exportMutation = useMutation({ - mutationFn: (data: ExportPayload) => - exportAuditPsi({ + mutationFn: ( + data: ExportPayload, + ): Promise<{ filename: string; content: string }> => + exportAuditLighthouseIssues({ data: { projectId, resultId, @@ -71,27 +65,56 @@ export function PsiIssuesScreen(props: PsiIssuesScreenProps) { selectedCategoryLabel, severityCounts, visibleIssues, - } = usePsiIssuesActions({ + } = useLighthouseIssuesActions({ category, exportMutation, - issues: (issuesQuery.data?.issues ?? []) as PsiIssue[], - summaryIssues: summaryQuery.data?.issues, + allIssues: issuesQuery.data?.issues ?? [], }); + const issuesErrorMessage = + issuesQuery.error instanceof Error + ? issuesQuery.error.message + : "Failed to load Lighthouse issues."; + const showsLegacyPayloadNotice = + issuesQuery.data != null && !issuesQuery.data.hasIssueDetails; + const emptyMessage = showsLegacyPayloadNotice + ? "This audit was saved without issue-level Lighthouse details. Re-run the audit to populate this screen." + : undefined; + return (
    -
    - + + {issuesErrorMessage} +
    + ) : null} + + {showsLegacyPayloadNotice ? ( +
    + + + This Lighthouse run was stored before issue details were + preserved. Re-run the audit to see category counts and issue + cards. + +
    + ) : null} + + -
    @@ -118,23 +142,23 @@ export function PsiIssuesScreen(props: PsiIssuesScreenProps) { ); } -function usePsiIssuesActions({ +function useLighthouseIssuesActions({ + allIssues, category, exportMutation, - issues, - summaryIssues, }: { + allIssues: LighthouseIssue[]; category: CategoryTab; exportMutation: { mutateAsync: ( data: ExportPayload, ) => Promise<{ filename: string; content: string }>; }; - issues: PsiIssue[]; - summaryIssues: PsiIssue[] | undefined; }) { - const visibleIssues = issues; - const allIssues = summaryIssues ?? visibleIssues; + const visibleIssues = + category === "all" + ? allIssues + : allIssues.filter((issue) => issue.category === category); const selectedCategoryLabel = categoryLabel(category); const categoryCounts = getCategoryCounts(allIssues); const severityCounts = getSeverityCounts(visibleIssues); @@ -151,8 +175,11 @@ function usePsiIssuesActions({ } }; - const runExportCsv = (rows: PsiIssue[], variant: "all" | "current") => { - const filename = `psi-${variant}-${categorySlug(category)}-issues.csv`; + const runExportCsv = ( + rows: LighthouseIssue[], + variant: "all" | "current", + ) => { + const filename = `lighthouse-${variant}-${categorySlug(category)}-issues.csv`; downloadTextFile(filename, issuesToCsv(rows), "text/csv"); toast.success("CSV download started"); }; @@ -181,7 +208,9 @@ function usePsiIssuesActions({ }; } -function getCategoryCounts(allIssues: PsiIssue[]): Record { +function getCategoryCounts( + allIssues: LighthouseIssue[], +): Record { return categoryTabs.reduce>( (acc, tab) => { if (tab === "all") { @@ -201,7 +230,7 @@ function getCategoryCounts(allIssues: PsiIssue[]): Record { ); } -function getSeverityCounts(issues: PsiIssue[]) { +function getSeverityCounts(issues: LighthouseIssue[]) { return { critical: issues.filter((issue) => issue.severity === "critical").length, warning: issues.filter((issue) => issue.severity === "warning").length, diff --git a/src/client/features/lighthouse/issues/LighthouseIssuesSummary.tsx b/src/client/features/lighthouse/issues/LighthouseIssuesSummary.tsx new file mode 100644 index 0000000..2a74222 --- /dev/null +++ b/src/client/features/lighthouse/issues/LighthouseIssuesSummary.tsx @@ -0,0 +1,121 @@ +import type { LighthouseMetrics, LighthouseScores } from "./types"; + +export function LighthouseIssuesSummary({ + scores, + metrics, +}: { + scores?: LighthouseScores | null; + metrics?: LighthouseMetrics | null; +}) { + const metricItems = getMetricItems(metrics); + + if (!scores && metricItems.length === 0) { + return null; + } + + return ( + <> + {scores ? ( +
    + + + + +
    + ) : null} + {metricItems.length > 0 ? ( +
    + {metricItems.map((metric) => ( +
    + + {metric.label} + + + {metric.value} + +
    + ))} +
    + ) : null} + + ); +} + +function scoreColor(score: number | null) { + if (score == null) return "text-base-content/40"; + if (score >= 90) return "text-success"; + if (score >= 50) return "text-warning"; + return "text-error"; +} + +function scoreStrokeColor(score: number | null) { + if (score == null) return "stroke-base-content/20"; + if (score >= 90) return "stroke-success"; + if (score >= 50) return "stroke-warning"; + return "stroke-error"; +} + +function ScoreGauge({ label, score }: { label: string; score: number | null }) { + const displayScore = score ?? 0; + const radius = 28; + const circumference = 2 * Math.PI * radius; + const progress = (displayScore / 100) * circumference; + + return ( +
    +
    + + + {score != null ? ( + + ) : null} + + + {score ?? "-"} + +
    + + {label} + +
    + ); +} + +function getMetricItems(metrics?: LighthouseMetrics | null) { + if (!metrics) return []; + + return [ + { label: "FCP", value: metrics.firstContentfulPaint.displayValue }, + { label: "LCP", value: metrics.largestContentfulPaint.displayValue }, + { label: "TBT", value: metrics.totalBlockingTime.displayValue }, + { label: "SI", value: metrics.speedIndex.displayValue }, + { label: "TTI", value: metrics.timeToInteractive.displayValue }, + { label: "CLS", value: metrics.cumulativeLayoutShift.displayValue }, + { label: "INP", value: metrics.interactionToNextPaint.displayValue }, + { label: "TTFB", value: metrics.serverResponseTime.displayValue }, + ].filter( + (metric): metric is { label: string; value: string } => + metric.value != null, + ); +} diff --git a/src/client/features/lighthouse/issues/types.ts b/src/client/features/lighthouse/issues/types.ts new file mode 100644 index 0000000..8c224f6 --- /dev/null +++ b/src/client/features/lighthouse/issues/types.ts @@ -0,0 +1,26 @@ +import type { z } from "zod"; +import type { getAuditLighthouseIssues } from "@/serverFunctions/lighthouse"; +import { + LIGHTHOUSE_CATEGORY_TABS, + type LighthouseCategoryTab, +} from "@/shared/lighthouse"; +import type { lighthouseAuditExportSchema } from "@/types/schemas/lighthouse"; + +export const categoryTabs = LIGHTHOUSE_CATEGORY_TABS; + +export type CategoryTab = LighthouseCategoryTab; + +export type ExportPayload = Omit< + z.infer, + "projectId" | "resultId" +>; + +type LighthouseIssuesResponse = Awaited< + ReturnType +>; + +export type LighthouseIssue = LighthouseIssuesResponse["issues"][number]; +export type LighthouseScores = NonNullable; +export type LighthouseMetrics = NonNullable< + LighthouseIssuesResponse["metrics"] +>; diff --git a/src/client/features/lighthouse/issues/utils.tsx b/src/client/features/lighthouse/issues/utils.tsx new file mode 100644 index 0000000..5ca1fc2 --- /dev/null +++ b/src/client/features/lighthouse/issues/utils.tsx @@ -0,0 +1,53 @@ +import { buildCsv } from "@/client/lib/csv"; +import type { CategoryTab, LighthouseIssue } from "./types"; + +export function categoryLabel(category: CategoryTab) { + if (category === "best-practices") return "Best practices"; + if (category === "all") return "All"; + return `${category.charAt(0).toUpperCase()}${category.slice(1)}`; +} + +export function categorySlug(category: CategoryTab) { + return category === "all" ? "all" : category; +} + +export function issuesToCsv(issues: LighthouseIssue[]) { + const headers = [ + "Category", + "Severity", + "Score", + "Title", + "Display Value", + "Description", + "Impact (ms)", + "Impact (bytes)", + "Affected Items", + ]; + + const rows = issues.map((issue) => [ + issue.category, + issue.severity, + issue.score ?? "", + issue.title, + issue.displayValue ?? "", + issue.description ?? "", + issue.impactMs ?? "", + issue.impactBytes ?? "", + issue.items.length, + ]); + + return buildCsv(headers, rows); +} + +export function downloadTextFile( + filename: string, + content: string, + mimeType: string, +) { + const blob = new Blob([content], { type: mimeType }); + const link = document.createElement("a"); + link.href = URL.createObjectURL(blob); + link.download = filename; + link.click(); + URL.revokeObjectURL(link.href); +} diff --git a/src/client/features/psi/issues/types.ts b/src/client/features/psi/issues/types.ts deleted file mode 100644 index 7489032..0000000 --- a/src/client/features/psi/issues/types.ts +++ /dev/null @@ -1,28 +0,0 @@ -export const categoryTabs = [ - "all", - "performance", - "accessibility", - "best-practices", - "seo", -] as const; - -export type CategoryTab = (typeof categoryTabs)[number]; -export type IssueCategory = Exclude; - -export type ExportPayload = { - mode: "full" | "issues" | "category"; - category?: IssueCategory; -}; - -export type PsiIssue = { - auditKey: string; - category: IssueCategory; - severity: "critical" | "warning" | "info"; - score?: number | null; - title: string; - displayValue?: string | null; - description?: string | null; - impactMs?: number | null; - impactBytes?: number | null; - items: string[]; -}; diff --git a/src/client/features/psi/issues/utils.tsx b/src/client/features/psi/issues/utils.tsx deleted file mode 100644 index 457e02f..0000000 --- a/src/client/features/psi/issues/utils.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import type { ReactNode } from "react"; -import { ExternalLink, FileWarning, Info, TriangleAlert } from "lucide-react"; -import { buildCsv } from "@/client/lib/csv"; -import type { CategoryTab, PsiIssue } from "./types"; - -export function categoryLabel(category: CategoryTab) { - if (category === "best-practices") return "Best practices"; - if (category === "all") return "All"; - return `${category.charAt(0).toUpperCase()}${category.slice(1)}`; -} - -export function categorySlug(category: CategoryTab) { - return category === "all" ? "all" : category; -} - -export function issuesToCsv(issues: PsiIssue[]) { - const headers = [ - "Category", - "Severity", - "Score", - "Title", - "Display Value", - "Description", - "Impact (ms)", - "Impact (bytes)", - "Affected Items", - ]; - - const rows = issues.map((issue) => [ - issue.category, - issue.severity, - issue.score ?? "", - issue.title, - issue.displayValue ?? "", - issue.description ?? "", - issue.impactMs ?? "", - issue.impactBytes ?? "", - issue.items.length, - ]); - - return buildCsv(headers, rows); -} - -export function renderInlineMarkdown(markdown: string): ReactNode { - const linkPattern = /\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g; - const nodes: ReactNode[] = []; - let cursor = 0; - let match = linkPattern.exec(markdown); - - while (match) { - const [raw, label, href] = match; - const index = match.index; - - if (index > cursor) { - nodes.push(markdown.slice(cursor, index)); - } - - nodes.push( - - {label} - - , - ); - - cursor = index + raw.length; - match = linkPattern.exec(markdown); - } - - if (cursor < markdown.length) { - nodes.push(markdown.slice(cursor)); - } - - if (!nodes.length) { - return markdown; - } - - return nodes; -} - -export function downloadTextFile( - filename: string, - content: string, - mimeType: string, -) { - const blob = new Blob([content], { type: mimeType }); - const link = document.createElement("a"); - link.href = URL.createObjectURL(blob); - link.download = filename; - link.click(); - URL.revokeObjectURL(link.href); -} - -export function severityBadgeClass(severity: "critical" | "warning" | "info") { - if (severity === "critical") { - return "border-error/30 bg-error/10 text-error/80"; - } - if (severity === "warning") { - return "border-warning/35 bg-warning/10 text-warning/80"; - } - return "border-info/30 bg-info/10 text-info/80"; -} - -export function severityIcon(severity: "critical" | "warning" | "info") { - if (severity === "critical") return ; - if (severity === "warning") return ; - return ; -} diff --git a/src/db/app.schema.ts b/src/db/app.schema.ts index b6e1370..654074f 100644 --- a/src/db/app.schema.ts +++ b/src/db/app.schema.ts @@ -27,9 +27,6 @@ export const projects = sqliteTable("projects", { .references(() => organization.id, { onDelete: "cascade" }), name: text("name").notNull(), domain: text("domain"), - // PSI keys are used for Google API abuse-control, not direct billing. - // We still keep handling explicit to make the tradeoff obvious. - pagespeedApiKey: text("pagespeed_api_key"), createdAt: text("created_at") .notNull() .default(sql`(current_timestamp)`), @@ -123,14 +120,14 @@ export const audits = sqliteTable( .notNull() .default("running"), workflowInstanceId: text("workflow_instance_id"), - // JSON config: { maxPages, psiStrategy, psiApiKey? } + // JSON config: { maxPages, lighthouseStrategy } config: text("config").notNull().default("{}"), // Progress & summary pagesCrawled: integer("pages_crawled").notNull().default(0), pagesTotal: integer("pages_total").notNull().default(0), - psiTotal: integer("psi_total").notNull().default(0), - psiCompleted: integer("psi_completed").notNull().default(0), - psiFailed: integer("psi_failed").notNull().default(0), + lighthouseTotal: integer("lighthouse_total").notNull().default(0), + lighthouseCompleted: integer("lighthouse_completed").notNull().default(0), + lighthouseFailed: integer("lighthouse_failed").notNull().default(0), currentPhase: text("current_phase").default("discovery"), startedAt: text("started_at") .notNull() @@ -196,10 +193,9 @@ export const auditPages = sqliteTable( (table) => [index("audit_pages_audit_id_idx").on(table.auditId)], ); -// PSI summaries captured as part of a site audit run. -// These belong to audit pages and are the only PSI result records we keep. -export const auditPsiResults = sqliteTable( - "audit_psi_results", +// One row per Lighthouse test (mobile + desktop per page). +export const auditLighthouseResults = sqliteTable( + "audit_lighthouse_results", { id: text("id").primaryKey(), auditId: text("audit_id") @@ -221,5 +217,5 @@ export const auditPsiResults = sqliteTable( r2Key: text("r2_key"), payloadSizeBytes: integer("payload_size_bytes"), }, - (table) => [index("audit_psi_results_audit_id_idx").on(table.auditId)], + (table) => [index("audit_lighthouse_results_audit_id_idx").on(table.auditId)], ); diff --git a/src/routes/_project/p/$projectId/audit/index.tsx b/src/routes/_project/p/$projectId/audit/index.tsx index d479795..ca860f2 100644 --- a/src/routes/_project/p/$projectId/audit/index.tsx +++ b/src/routes/_project/p/$projectId/audit/index.tsx @@ -199,9 +199,9 @@ function ProgressCard({ status: { pagesCrawled: number; pagesTotal: number; - psiTotal: number; - psiCompleted: number; - psiFailed: number; + lighthouseTotal: number; + lighthouseCompleted: number; + lighthouseFailed: number; currentPhase: string | null; }; }) { @@ -209,21 +209,23 @@ function ProgressCard({ status.pagesTotal > 0 ? Math.round((status.pagesCrawled / status.pagesTotal) * 100) : 0; - const psiDone = status.psiCompleted + status.psiFailed; - const psiProgress = - status.psiTotal > 0 ? Math.round((psiDone / status.psiTotal) * 100) : 0; - const isPsiPhase = status.currentPhase === "psi"; + const lighthouseDone = status.lighthouseCompleted + status.lighthouseFailed; + const lighthouseProgress = + status.lighthouseTotal > 0 + ? Math.round((lighthouseDone / status.lighthouseTotal) * 100) + : 0; + const isLighthousePhase = status.currentPhase === "lighthouse"; const phaseLabel = status.currentPhase === "discovery" ? "Discovery" : status.currentPhase === "crawling" ? "Crawling" - : status.currentPhase === "psi" - ? "PSI" + : status.currentPhase === "lighthouse" + ? "Lighthouse" : status.currentPhase === "finalizing" ? "Finalizing" : (status.currentPhase ?? "Running"); - const progress = isPsiPhase ? psiProgress : crawlProgress; + const progress = isLighthousePhase ? lighthouseProgress : crawlProgress; const crawlProgressQuery = useQuery({ queryKey: ["audit-crawl-progress", projectId, auditId], @@ -240,7 +242,9 @@ function ProgressCard({

    - {isPsiPhase ? "Running PSI checks" : "Crawling pages"} + {isLighthousePhase + ? "Running Lighthouse checks" + : "Crawling pages"}

    {phaseLabel}
    @@ -252,10 +256,12 @@ function ProgressCard({ />
    - {isPsiPhase ? ( + {isLighthousePhase ? ( - {psiDone} / {status.psiTotal} checks - {status.psiFailed > 0 ? ` (${status.psiFailed} failed)` : ""} + {lighthouseDone} / {status.lighthouseTotal} checks + {status.lighthouseFailed > 0 + ? ` (${status.lighthouseFailed} failed)` + : ""} ) : ( diff --git a/src/routes/_project/p/$projectId/audit/issues/$resultId.tsx b/src/routes/_project/p/$projectId/audit/issues/$resultId.tsx index 0978313..15ae4e1 100644 --- a/src/routes/_project/p/$projectId/audit/issues/$resultId.tsx +++ b/src/routes/_project/p/$projectId/audit/issues/$resultId.tsx @@ -1,21 +1,21 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router"; -import { PsiIssuesScreen } from "@/client/features/psi/issues/PsiIssuesScreen"; -import { psiIssuesSearchSchema } from "@/types/schemas/psi"; +import { LighthouseIssuesScreen } from "@/client/features/lighthouse/issues/LighthouseIssuesScreen"; +import { lighthouseIssuesSearchSchema } from "@/types/schemas/lighthouse"; export const Route = createFileRoute( "/_project/p/$projectId/audit/issues/$resultId", )({ - validateSearch: psiIssuesSearchSchema, + validateSearch: lighthouseIssuesSearchSchema, component: AuditIssuesPage, }); function AuditIssuesPage() { const { projectId, resultId } = Route.useParams(); - const { category } = Route.useSearch(); + const { auditId, category } = Route.useSearch(); const navigate = useNavigate({ from: Route.fullPath }); return ( - diff --git a/src/server/features/audit/repositories/AuditRepository.ts b/src/server/features/audit/repositories/AuditRepository.ts index 6165724..0cf048f 100644 --- a/src/server/features/audit/repositories/AuditRepository.ts +++ b/src/server/features/audit/repositories/AuditRepository.ts @@ -1,13 +1,30 @@ /** * Data access layer for site audit tables. - * All D1 interactions for audits, audit_pages, and audit_psi_results. + * All D1 interactions for audits, audit_pages, and stored Lighthouse results. */ -import { db } from "@/db"; -import { audits, auditPages, auditPsiResults } from "@/db/schema"; import { and, desc, eq } from "drizzle-orm"; -import type { PsiResult, AuditConfig } from "@/server/lib/audit/types"; +import { db } from "@/db"; +import { audits, auditLighthouseResults, auditPages } from "@/db/schema"; +import type { + AuditConfig, + LighthouseResult, + StepPageResult, +} from "@/server/lib/audit/types"; -// ─── Create ────────────────────────────────────────────────────────────────── +const DB_BATCH_SIZE = 100; +type BatchStatement = Parameters[0][number]; + +async function executeInBatches( + items: T[], + buildStatement: (item: T) => BatchStatement, +) { + for (let i = 0; i < items.length; i += DB_BATCH_SIZE) { + const chunk = items.slice(i, i + DB_BATCH_SIZE).map(buildStatement); + const [first, ...rest] = chunk; + if (!first) continue; + await db.batch([first, ...rest]); + } +} async function createAudit(data: { id: string; @@ -17,7 +34,7 @@ async function createAudit(data: { workflowInstanceId: string; config: AuditConfig; pagesTotal: number; - psiTotal: number; + lighthouseTotal: number; }) { await db.insert(audits).values({ id: data.id, @@ -28,22 +45,20 @@ async function createAudit(data: { config: JSON.stringify(data.config), status: "running", pagesTotal: data.pagesTotal, - psiTotal: data.psiTotal, + lighthouseTotal: data.lighthouseTotal, currentPhase: "discovery", }); } -// ─── Update ────────────────────────────────────────────────────────────────── - async function updateAuditProgress( auditId: string, workflowInstanceId: string, data: { pagesCrawled?: number; pagesTotal?: number; - psiTotal?: number; - psiCompleted?: number; - psiFailed?: number; + lighthouseTotal?: number; + lighthouseCompleted?: number; + lighthouseFailed?: number; currentPhase?: string; }, ) { @@ -110,132 +125,70 @@ async function getAuditForWorkflow( }); } -// ─── Batch write results (finalize step) ───────────────────────────────────── - -/** - * Use db.batch() to send individual INSERT statements in a single round-trip. - * D1's batch API supports up to 100 *statements* per call — each statement - * has its own bind params, so there's no per-statement param limit issue. - */ async function batchWriteResults( auditId: string, - pages: Array<{ - id: string; - url: string; - statusCode: number; - redirectUrl: string | null; - title: string; - metaDescription: string; - canonicalUrl: string | null; - robotsMeta: string | null; - ogTitle: string | null; - ogDescription: string | null; - ogImage: string | null; - h1Count: number; - h2Count: number; - h3Count: number; - h4Count: number; - h5Count: number; - h6Count: number; - headingOrder: number[]; - wordCount: number; - imagesTotal: number; - imagesMissingAlt: number; - images: Array<{ src: string | null; alt: string | null }>; - internalLinks: string[]; - externalLinks: string[]; - hasStructuredData: boolean; - hreflangTags: string[]; - isIndexable: boolean; - responseTimeMs: number; - }>, - psiResults: PsiResult[], + pages: StepPageResult[], + lighthouseResults: LighthouseResult[], ) { - const BATCH_SIZE = 100; // D1 max statements per batch() call - - // ── Pages ────────────────────────────────────────────────────────── - const pageStatements = pages.map((p) => + await executeInBatches(pages, (page) => db.insert(auditPages).values({ - id: p.id, + id: page.id, auditId, - url: p.url, - statusCode: p.statusCode, - redirectUrl: p.redirectUrl, - // Metadata - title: p.title, - metaDescription: p.metaDescription, - canonicalUrl: p.canonicalUrl, - robotsMeta: p.robotsMeta, - // Open Graph - ogTitle: p.ogTitle, - ogDescription: p.ogDescription, - ogImage: p.ogImage, - // Headings - h1Count: p.h1Count, - h2Count: p.h2Count, - h3Count: p.h3Count, - h4Count: p.h4Count, - h5Count: p.h5Count, - h6Count: p.h6Count, - headingOrderJson: JSON.stringify(p.headingOrder), - // Content - wordCount: p.wordCount, - // Images - imagesTotal: p.imagesTotal, - imagesMissingAlt: p.imagesMissingAlt, - imagesJson: JSON.stringify(p.images), - // Links - internalLinkCount: p.internalLinks.length, - externalLinkCount: p.externalLinks.length, - // Structured data - hasStructuredData: p.hasStructuredData, - // Hreflang - hreflangTagsJson: JSON.stringify(p.hreflangTags), - // Indexability - isIndexable: p.isIndexable, - // Performance - responseTimeMs: p.responseTimeMs, + url: page.url, + statusCode: page.statusCode, + redirectUrl: page.redirectUrl, + title: page.title, + metaDescription: page.metaDescription, + canonicalUrl: page.canonicalUrl, + robotsMeta: page.robotsMeta, + ogTitle: page.ogTitle, + ogDescription: page.ogDescription, + ogImage: page.ogImage, + h1Count: page.h1Count, + h2Count: page.h2Count, + h3Count: page.h3Count, + h4Count: page.h4Count, + h5Count: page.h5Count, + h6Count: page.h6Count, + headingOrderJson: JSON.stringify(page.headingOrder), + wordCount: page.wordCount, + imagesTotal: page.imagesTotal, + imagesMissingAlt: page.imagesMissingAlt, + imagesJson: JSON.stringify(page.images), + internalLinkCount: page.internalLinks.length, + externalLinkCount: page.externalLinks.length, + hasStructuredData: page.hasStructuredData, + hreflangTagsJson: JSON.stringify(page.hreflangTags), + isIndexable: page.isIndexable, + responseTimeMs: page.responseTimeMs, }), ); - for (let i = 0; i < pageStatements.length; i += BATCH_SIZE) { - const chunk = pageStatements.slice(i, i + BATCH_SIZE); - const [first, ...rest] = chunk; - await db.batch([first, ...rest]); + if (lighthouseResults.length === 0) { + return; } - // ── PSI results ──────────────────────────────────────────────────── - if (psiResults.length > 0) { - const psiStatements = psiResults.map((r) => - db.insert(auditPsiResults).values({ - id: crypto.randomUUID(), - auditId, - pageId: r.pageId, - strategy: r.strategy, - performanceScore: r.performanceScore, - accessibilityScore: r.accessibilityScore, - bestPracticesScore: r.bestPracticesScore, - seoScore: r.seoScore, - lcpMs: r.lcpMs, - cls: r.cls, - inpMs: r.inpMs, - ttfbMs: r.ttfbMs, - errorMessage: r.errorMessage ?? null, - r2Key: r.r2Key ?? null, - payloadSizeBytes: r.payloadSizeBytes ?? null, - }), - ); - - for (let i = 0; i < psiStatements.length; i += BATCH_SIZE) { - const chunk = psiStatements.slice(i, i + BATCH_SIZE); - const [first, ...rest] = chunk; - await db.batch([first, ...rest]); - } - } + await executeInBatches(lighthouseResults, (result) => + db.insert(auditLighthouseResults).values({ + id: crypto.randomUUID(), + auditId, + pageId: result.pageId, + strategy: result.strategy, + performanceScore: result.performanceScore, + accessibilityScore: result.accessibilityScore, + bestPracticesScore: result.bestPracticesScore, + seoScore: result.seoScore, + lcpMs: result.lcpMs, + cls: result.cls, + inpMs: result.inpMs, + ttfbMs: result.ttfbMs, + errorMessage: result.errorMessage ?? null, + r2Key: result.r2Key ?? null, + payloadSizeBytes: result.payloadSizeBytes ?? null, + }), + ); } -// ─── Read ──────────────────────────────────────────────────────────────────── - async function getAuditForProject(auditId: string, projectId: string) { return db.query.audits.findFirst({ where: and(eq(audits.id, auditId), eq(audits.projectId, projectId)), @@ -252,78 +205,80 @@ async function getAuditsByProject(projectId: string) { return rows.map(({ audit }) => audit); } -async function getAuditResultsForProject(auditId: string, projectId: string) { - const audit = await getAuditForProject(auditId, projectId); - if (!audit) { - return { audit: null, pages: [], psi: [] }; - } - - const [pages, psi] = await Promise.all([ - db.query.auditPages.findMany({ - where: eq(auditPages.auditId, auditId), - }), - db.query.auditPsiResults.findMany({ - where: eq(auditPsiResults.auditId, auditId), - }), - ]); - - return { audit, pages, psi }; -} - async function getAuditCapacityUsageForUser(userId: string) { const rows = await db.query.audits.findMany({ where: eq(audits.startedByUserId, userId), columns: { pagesTotal: true, - psiTotal: true, + lighthouseTotal: true, }, }); - return rows.reduce((total, row) => total + row.pagesTotal + row.psiTotal, 0); + return rows.reduce( + (total, row) => total + row.pagesTotal + row.lighthouseTotal, + 0, + ); } -async function getPsiResultById(input: { - psiResultId: string; - projectId: string; -}) { - const psi = await db.query.auditPsiResults.findFirst({ - where: eq(auditPsiResults.id, input.psiResultId), - }); - - if (!psi) return null; - - const parentAudit = await db.query.audits.findFirst({ - where: and( - eq(audits.id, psi.auditId), - eq(audits.projectId, input.projectId), - ), - }); - - if (!parentAudit) { - throw new Error("Audit not found"); +async function getAuditResultsForProject(auditId: string, projectId: string) { + const audit = await getAuditForProject(auditId, projectId); + if (!audit) { + return { audit: null, pages: [], lighthouse: [] }; } - const page = await db.query.auditPages.findFirst({ - where: eq(auditPages.id, psi.pageId), + const [pages, lighthouse] = await Promise.all([ + db.query.auditPages.findMany({ + where: eq(auditPages.auditId, auditId), + }), + db.query.auditLighthouseResults.findMany({ + where: eq(auditLighthouseResults.auditId, auditId), + }), + ]); + + return { audit, pages, lighthouse }; +} + +async function getLighthouseResultById(input: { + lighthouseResultId: string; + projectId: string; +}) { + const lighthouse = await db.query.auditLighthouseResults.findFirst({ + where: eq(auditLighthouseResults.id, input.lighthouseResultId), }); + if (!lighthouse) { + return null; + } + + const [parentAudit, page] = await Promise.all([ + db.query.audits.findFirst({ + where: and( + eq(audits.id, lighthouse.auditId), + eq(audits.projectId, input.projectId), + ), + }), + db.query.auditPages.findFirst({ + where: eq(auditPages.id, lighthouse.pageId), + }), + ]); + + if (!parentAudit) { + return null; + } + return { - psi, + lighthouse, page, audit: parentAudit, }; } -// ─── Delete ────────────────────────────────────────────────────────────────── - async function deleteAuditForProject(auditId: string, projectId: string) { await db .delete(audits) .where(and(eq(audits.id, auditId), eq(audits.projectId, projectId))); } -// ─── Export ────────────────────────────────────────────────────────────────── - export const AuditRepository = { createAudit, updateAuditProgress, @@ -333,8 +288,8 @@ export const AuditRepository = { batchWriteResults, getAuditForProject, getAuditsByProject, - getAuditResultsForProject, getAuditCapacityUsageForUser, - getPsiResultById, + getAuditResultsForProject, + getLighthouseResultById, deleteAuditForProject, } as const; diff --git a/src/server/features/audit/services/AuditService.ts b/src/server/features/audit/services/AuditService.ts index 4a74f86..1e29d22 100644 --- a/src/server/features/audit/services/AuditService.ts +++ b/src/server/features/audit/services/AuditService.ts @@ -1,50 +1,33 @@ -/** - * Business logic layer for site audits. - * Orchestrates between the workflow trigger, repository, and data formatting. - */ import { env } from "cloudflare:workers"; +import type { BillingCustomerContext } from "@/server/billing/subscription"; import { AuditRepository } from "@/server/features/audit/repositories/AuditRepository"; -import { AuditProgressKV } from "@/server/lib/audit/progress-kv"; -import { normalizeAndValidateStartUrl } from "@/server/lib/audit/url-policy"; -import { AppError } from "@/server/lib/errors"; -import type { AuditConfig, PsiStrategy } from "@/server/lib/audit/types"; -import { ProjectRepository } from "@/server/features/projects/repositories/ProjectRepository"; import { + MAX_USER_AUDIT_USAGE, clampAuditMaxPages, getEstimatedAuditCapacity, - MAX_USER_AUDIT_USAGE, } from "@/server/features/audit/services/audit-capacity"; -import { jsonCodec } from "@/shared/json"; -import { z } from "zod"; - -const auditConfigSchema = z.object({ - maxPages: z.number().int().min(10).max(10_000), - psiStrategy: z.enum(["auto", "all", "manual", "none"]), - psiApiKey: z.string().optional(), -}); - -const auditConfigCodec = jsonCodec(auditConfigSchema); - -function parseAuditConfig(configRaw: string | null): AuditConfig | null { - if (!configRaw) return null; - const result = auditConfigCodec.safeParse(configRaw); - return result.success ? result.data : null; -} +import { AppError } from "@/server/lib/errors"; +import { AuditProgressKV } from "@/server/lib/audit/progress-kv"; +import { + parseAuditConfig, + type AuditConfig, + type LighthouseStrategy, +} from "@/server/lib/audit/types"; +import { normalizeAndValidateStartUrl } from "@/server/lib/audit/url-policy"; async function startAudit(input: { actorUserId: string; + billingCustomer: BillingCustomerContext; projectId: string; startUrl: string; maxPages?: number; - psiStrategy?: PsiStrategy; - psiApiKey?: string; + lighthouseStrategy?: LighthouseStrategy; }) { const maxPages = clampAuditMaxPages(input.maxPages); - const psiStrategy = input.psiStrategy ?? "auto"; - + const lighthouseStrategy = input.lighthouseStrategy ?? "auto"; const reservation = getEstimatedAuditCapacity({ maxPages, - psiStrategy, + lighthouseStrategy, }); const currentUsage = await AuditRepository.getAuditCapacityUsageForUser( @@ -56,27 +39,7 @@ async function startAudit(input: { } const auditId = crypto.randomUUID(); - - const shouldRunPsi = psiStrategy !== "none"; - let resolvedPsiApiKey = input.psiApiKey?.trim(); - - if (shouldRunPsi && !resolvedPsiApiKey) { - resolvedPsiApiKey = - (await ProjectRepository.getProjectPsiApiKey(input.projectId)) ?? - undefined; - } - - if (shouldRunPsi && !resolvedPsiApiKey) { - throw new Error("PSI API key is not set for this project."); - } - - const config: AuditConfig = { - maxPages, - psiStrategy, - // PSI key is used for Google quota/abuse control (non-billing). - psiApiKey: resolvedPsiApiKey, - }; - + const config: AuditConfig = { maxPages, lighthouseStrategy }; const startUrl = await normalizeAndValidateStartUrl(input.startUrl); await AuditRepository.createAudit({ @@ -87,15 +50,15 @@ async function startAudit(input: { workflowInstanceId: auditId, config, pagesTotal: reservation.pagesTotal, - psiTotal: reservation.psiTotal, + lighthouseTotal: reservation.lighthouseTotal, }); - // Trigger the Cloudflare Workflow try { await env.SITE_AUDIT_WORKFLOW.create({ id: auditId, params: { auditId, + billingCustomer: input.billingCustomer, projectId: input.projectId, startUrl, config, @@ -108,6 +71,7 @@ async function startAudit(input: { } catch { // The workflow may never have been created, or may already be gone. } + await AuditRepository.deleteAuditForProject(auditId, input.projectId); throw error; } @@ -125,9 +89,9 @@ async function getStatus(auditId: string, projectId: string) { status: audit.status, pagesCrawled: audit.pagesCrawled, pagesTotal: audit.pagesTotal, - psiTotal: audit.psiTotal, - psiCompleted: audit.psiCompleted, - psiFailed: audit.psiFailed, + lighthouseTotal: audit.lighthouseTotal, + lighthouseCompleted: audit.lighthouseCompleted, + lighthouseFailed: audit.lighthouseFailed, currentPhase: audit.currentPhase, startedAt: audit.startedAt, completedAt: audit.completedAt, @@ -135,10 +99,8 @@ async function getStatus(auditId: string, projectId: string) { } async function getResults(auditId: string, projectId: string) { - const { audit, pages, psi } = await AuditRepository.getAuditResultsForProject( - auditId, - projectId, - ); + const { audit, pages, lighthouse } = + await AuditRepository.getAuditResultsForProject(auditId, projectId); if (!audit) throw new AppError("NOT_FOUND"); @@ -146,7 +108,6 @@ async function getResults(auditId: string, projectId: string) { if (!parsedConfig) { throw new AppError("INTERNAL_ERROR", "Invalid audit configuration"); } - const { psiApiKey: _psiApiKey, ...safeConfig } = parsedConfig; return { audit: { @@ -157,31 +118,31 @@ async function getResults(auditId: string, projectId: string) { pagesTotal: audit.pagesTotal, startedAt: audit.startedAt, completedAt: audit.completedAt, - config: safeConfig, + config: parsedConfig, }, pages, - psi, + lighthouse, }; } async function getHistory(projectId: string) { const auditList = await AuditRepository.getAuditsByProject(projectId); - const didRunPsi = (configRaw: string | null) => { - const parsed = parseAuditConfig(configRaw); - return parsed?.psiStrategy != null && parsed.psiStrategy !== "none"; - }; + return auditList.map((audit) => { + const parsedConfig = parseAuditConfig(audit.config); + const ranLighthouse = parsedConfig?.lighthouseStrategy !== "none"; - return auditList.map((a) => ({ - id: a.id, - startUrl: a.startUrl, - status: a.status, - pagesCrawled: a.pagesCrawled, - pagesTotal: a.pagesTotal, - ranPsi: didRunPsi(a.config), - startedAt: a.startedAt, - completedAt: a.completedAt, - })); + return { + id: audit.id, + startUrl: audit.startUrl, + status: audit.status, + pagesCrawled: audit.pagesCrawled, + pagesTotal: audit.pagesTotal, + ranLighthouse, + startedAt: audit.startedAt, + completedAt: audit.completedAt, + }; + }); } async function getCrawlProgress(auditId: string, projectId: string) { @@ -189,6 +150,7 @@ async function getCrawlProgress(auditId: string, projectId: string) { if (!audit) { throw new AppError("NOT_FOUND"); } + return AuditProgressKV.getCrawledUrls(auditId); } @@ -197,6 +159,7 @@ async function remove(auditId: string, projectId: string) { if (!audit) { throw new AppError("NOT_FOUND"); } + if (audit.status === "running") { if (!audit.workflowInstanceId) { throw new AppError( @@ -215,6 +178,7 @@ async function remove(auditId: string, projectId: string) { throw new AppError("CONFLICT", "Unable to stop the running audit."); } } + await AuditRepository.deleteAuditForProject(auditId, projectId); } diff --git a/src/server/features/audit/services/audit-capacity.test.ts b/src/server/features/audit/services/audit-capacity.test.ts index a38a6e4..0739e56 100644 --- a/src/server/features/audit/services/audit-capacity.test.ts +++ b/src/server/features/audit/services/audit-capacity.test.ts @@ -13,41 +13,46 @@ describe("audit capacity helpers", () => { expect(clampAuditMaxPages(20_000)).toBe(10_000); }); - it("estimates capacity for each psi strategy", () => { + it("estimates capacity for each lighthouse strategy", () => { expect( - getEstimatedAuditCapacity({ maxPages: 100, psiStrategy: "none" }), + getEstimatedAuditCapacity({ maxPages: 100, lighthouseStrategy: "none" }), ).toEqual({ pagesTotal: 100, - psiTotal: 0, + lighthouseTotal: 0, total: 100, }); expect( - getEstimatedAuditCapacity({ maxPages: 100, psiStrategy: "manual" }), + getEstimatedAuditCapacity({ + maxPages: 100, + lighthouseStrategy: "manual", + }), ).toEqual({ pagesTotal: 100, - psiTotal: 0, + lighthouseTotal: 0, total: 100, }); expect( - getEstimatedAuditCapacity({ maxPages: 100, psiStrategy: "auto" }), + getEstimatedAuditCapacity({ maxPages: 100, lighthouseStrategy: "auto" }), ).toEqual({ pagesTotal: 100, - psiTotal: 20, + lighthouseTotal: 20, total: 120, }); expect( - getEstimatedAuditCapacity({ maxPages: 100, psiStrategy: "all" }), + getEstimatedAuditCapacity({ maxPages: 100, lighthouseStrategy: "all" }), ).toEqual({ pagesTotal: 100, - psiTotal: 200, + lighthouseTotal: 200, total: 300, }); }); it("stays within the global capacity limit for the maximum auto audit", () => { expect( - getEstimatedAuditCapacity({ maxPages: 10_000, psiStrategy: "auto" }) - .total, + getEstimatedAuditCapacity({ + maxPages: 10_000, + lighthouseStrategy: "auto", + }).total, ).toBeLessThan(MAX_USER_AUDIT_USAGE); }); }); diff --git a/src/server/features/audit/services/audit-capacity.ts b/src/server/features/audit/services/audit-capacity.ts index 97f6da5..9e5cf47 100644 --- a/src/server/features/audit/services/audit-capacity.ts +++ b/src/server/features/audit/services/audit-capacity.ts @@ -1,4 +1,4 @@ -import type { PsiStrategy } from "@/server/lib/audit/types"; +import type { LighthouseStrategy } from "@/server/lib/audit/types"; export const MAX_USER_AUDIT_USAGE = 100_000; @@ -8,28 +8,28 @@ export function clampAuditMaxPages(maxPages?: number) { export function getEstimatedAuditCapacity(input: { maxPages?: number; - psiStrategy?: PsiStrategy; + lighthouseStrategy?: LighthouseStrategy; }) { const pagesTotal = clampAuditMaxPages(input.maxPages); - const psiStrategy = input.psiStrategy ?? "auto"; + const lighthouseStrategy = input.lighthouseStrategy ?? "auto"; - let psiTotal = 0; - switch (psiStrategy) { + let lighthouseChecks = 0; + switch (lighthouseStrategy) { case "all": - psiTotal = pagesTotal * 2; + lighthouseChecks = pagesTotal * 2; break; case "auto": - psiTotal = 20; + lighthouseChecks = 20; break; case "manual": case "none": - psiTotal = 0; + lighthouseChecks = 0; break; } return { pagesTotal, - psiTotal, - total: pagesTotal + psiTotal, + lighthouseTotal: lighthouseChecks, + total: pagesTotal + lighthouseChecks, }; } diff --git a/src/server/features/lighthouse/services/lighthouse-export.test.ts b/src/server/features/lighthouse/services/lighthouse-export.test.ts new file mode 100644 index 0000000..0140047 --- /dev/null +++ b/src/server/features/lighthouse/services/lighthouse-export.test.ts @@ -0,0 +1,170 @@ +import { z } from "zod"; +import { describe, expect, it } from "vitest"; +import { buildLighthouseExportFile } from "@/server/lib/lighthousePayload"; + +const storedPayloadJson = JSON.stringify({ + version: 2, + source: "dataforseo-lighthouse", + hasIssueDetails: true, + metadata: { + requestedUrl: "https://everyapp.dev/blog/enable-mfa-rdp-ssh", + finalUrl: "https://everyapp.dev/blog/enable-mfa-rdp-ssh", + strategy: "mobile", + fetchedAt: "2026-03-23T19:27:33.000Z", + lighthouseVersion: "12.2.0", + taskId: "task-1", + cost: 0.00425, + }, + scores: { + performance: 89, + accessibility: 93, + "best-practices": 92, + seo: 91, + }, + metrics: { + firstContentfulPaint: { + score: 47, + displayValue: "3.1 s", + numericValue: 3100, + }, + largestContentfulPaint: { + score: 12, + displayValue: "6.4 s", + numericValue: 6400, + }, + totalBlockingTime: { + score: 79, + displayValue: "290 ms", + numericValue: 290, + }, + cumulativeLayoutShift: { + score: 92, + displayValue: "0.03", + numericValue: 0.03, + }, + speedIndex: { + score: 86, + displayValue: "3.7 s", + numericValue: 3700, + }, + timeToInteractive: { + score: 13, + displayValue: "12.8 s", + numericValue: 12800, + }, + interactionToNextPaint: { + score: null, + displayValue: null, + numericValue: null, + }, + serverResponseTime: { + score: 90, + displayValue: "52 ms", + numericValue: 52, + }, + }, + issues: [ + { + category: "performance", + auditKey: "unused-javascript", + title: "Reduce unused JavaScript", + description: "Trim dead code.", + score: 50, + scoreDisplayMode: "metricSavings", + displayValue: "Potential savings of 227 KiB", + impactMs: 0, + impactBytes: 232886, + severity: "critical", + items: [], + }, + { + category: "accessibility", + auditKey: "color-contrast", + title: + "Background and foreground colors do not have a sufficient contrast ratio.", + description: "Improve contrast.", + score: 0, + scoreDisplayMode: "binary", + displayValue: null, + impactMs: null, + impactBytes: null, + severity: "critical", + items: [], + }, + ], +}); + +const issuesExportSchema = z.object({ + resultId: z.string(), + category: z.string(), + issues: z.array( + z.object({ + auditKey: z.string(), + category: z.string(), + }), + ), +}); + +describe("buildLighthouseExportFile", () => { + it("exports the stored payload unchanged for full mode", () => { + const exported = buildLighthouseExportFile({ + idField: "resultId", + idValue: "result-1", + finalUrl: "https://everyapp.dev/blog/enable-mfa-rdp-ssh", + strategy: "mobile", + createdAt: "2026-03-23T19:27:33.000Z", + payloadJson: storedPayloadJson, + mode: "full", + }); + + expect(exported.filename).toContain("-payload.json"); + expect(exported.content).toBe(storedPayloadJson); + }); + + it("exports only actionable issues for issues mode", () => { + const exported = buildLighthouseExportFile({ + idField: "resultId", + idValue: "result-1", + finalUrl: "https://everyapp.dev/blog/enable-mfa-rdp-ssh", + strategy: "mobile", + createdAt: "2026-03-23T19:27:33.000Z", + payloadJson: storedPayloadJson, + mode: "issues", + }); + + const content = issuesExportSchema.parse(JSON.parse(exported.content)); + + expect(exported.filename).toContain("-issues.json"); + expect(content.resultId).toBe("result-1"); + expect(content.category).toBe("all"); + expect(content.issues.map((issue) => issue.auditKey)).toEqual([ + "unused-javascript", + "color-contrast", + ]); + expect(exported.content).not.toContain("timeToInteractive"); + }); + + it("exports only the selected category for category mode", () => { + const exported = buildLighthouseExportFile({ + idField: "resultId", + idValue: "result-1", + finalUrl: "https://everyapp.dev/blog/enable-mfa-rdp-ssh", + strategy: "mobile", + createdAt: "2026-03-23T19:27:33.000Z", + payloadJson: storedPayloadJson, + mode: "category", + category: "accessibility", + }); + + const content = issuesExportSchema.parse(JSON.parse(exported.content)); + + expect(exported.filename).toContain("-accessibility-issues.json"); + expect(content.category).toBe("accessibility"); + expect(content.issues).toEqual([ + expect.objectContaining({ + auditKey: "color-contrast", + category: "accessibility", + }), + ]); + }); +}); diff --git a/src/server/features/projects/repositories/ProjectRepository.ts b/src/server/features/projects/repositories/ProjectRepository.ts index 8a61294..629e9c2 100644 --- a/src/server/features/projects/repositories/ProjectRepository.ts +++ b/src/server/features/projects/repositories/ProjectRepository.ts @@ -28,28 +28,6 @@ async function getProjectById(projectId: string) { }); } -async function getProjectPsiApiKey(projectId: string) { - const project = await db.query.projects.findFirst({ - where: eq(projects.id, projectId), - columns: { pagespeedApiKey: true }, - }); - return project?.pagespeedApiKey ?? null; -} - -async function setProjectPsiApiKey(projectId: string, apiKey: string) { - await db - .update(projects) - .set({ pagespeedApiKey: apiKey }) - .where(eq(projects.id, projectId)); -} - -async function clearProjectPsiApiKey(projectId: string) { - await db - .update(projects) - .set({ pagespeedApiKey: null }) - .where(eq(projects.id, projectId)); -} - async function createProject( organizationId: string, name: string, @@ -85,9 +63,6 @@ export const ProjectRepository = { listProjects, getProjectForOrganization, getProjectById, - getProjectPsiApiKey, - setProjectPsiApiKey, - clearProjectPsiApiKey, createProject, deleteProject, } as const; diff --git a/src/server/features/psi/services/PsiAuditService.ts b/src/server/features/psi/services/PsiAuditService.ts deleted file mode 100644 index f8dc50d..0000000 --- a/src/server/features/psi/services/PsiAuditService.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { AppError } from "@/server/lib/errors"; -import { getJsonFromR2 } from "@/server/lib/r2"; -import { AuditRepository } from "@/server/features/audit/repositories/AuditRepository"; -import { ProjectRepository } from "@/server/features/projects/repositories/ProjectRepository"; -import { - PsiIssuesService, - type PsiIssueCategory, -} from "@/server/features/psi/services/PsiIssuesService"; -import { buildPsiExportFile } from "@/server/features/psi/services/psi-export"; - -type PsiStrategy = "mobile" | "desktop"; -type ExportMode = "full" | "issues" | "category"; - -type AuditPsiTarget = { - id: string; - strategy: PsiStrategy; - finalUrl: string; - createdAt: string; - r2Key: string | null; -}; - -async function getAuditPsiTarget(input: { - projectId: string; - resultId: string; -}): Promise { - const site = await AuditRepository.getPsiResultById({ - psiResultId: input.resultId, - projectId: input.projectId, - }); - - if (!site) { - throw new AppError("NOT_FOUND"); - } - - return { - id: site.psi.id, - strategy: site.psi.strategy, - finalUrl: site.page?.url ?? "", - createdAt: site.audit.startedAt, - r2Key: site.psi.r2Key, - }; -} - -async function getProjectPsiApiKey(input: { projectId: string }) { - const apiKey = await ProjectRepository.getProjectPsiApiKey(input.projectId); - return { apiKey }; -} - -async function saveProjectPsiApiKey(input: { - projectId: string; - apiKey: string; -}) { - await ProjectRepository.setProjectPsiApiKey( - input.projectId, - input.apiKey.trim(), - ); - return { success: true }; -} - -async function clearProjectPsiApiKey(input: { projectId: string }) { - await ProjectRepository.clearProjectPsiApiKey(input.projectId); - return { success: true }; -} - -async function getAuditPsiIssues(input: { - projectId: string; - resultId: string; - category?: PsiIssueCategory; -}) { - const target = await getAuditPsiTarget(input); - if (!target.r2Key) { - throw new AppError("NOT_FOUND"); - } - - const payloadJson = await getJsonFromR2(target.r2Key); - const issues = PsiIssuesService.parseIssues(payloadJson, input.category); - - return { - id: target.id, - finalUrl: target.finalUrl, - strategy: target.strategy, - createdAt: target.createdAt, - issues, - }; -} - -async function exportAuditPsi(input: { - projectId: string; - resultId: string; - mode: ExportMode; - category?: PsiIssueCategory; -}) { - const target = await getAuditPsiTarget(input); - if (!target.r2Key) { - throw new AppError("NOT_FOUND"); - } - - const payloadJson = await getJsonFromR2(target.r2Key); - - return buildPsiExportFile({ - idField: "resultId", - idValue: target.id, - finalUrl: target.finalUrl, - strategy: target.strategy, - createdAt: target.createdAt, - payloadJson, - mode: input.mode, - category: input.mode === "category" ? input.category : undefined, - }); -} - -export const PsiAuditService = { - getProjectPsiApiKey, - saveProjectPsiApiKey, - clearProjectPsiApiKey, - getAuditPsiIssues, - exportAuditPsi, -} as const; diff --git a/src/server/features/psi/services/PsiIssuesService.ts b/src/server/features/psi/services/PsiIssuesService.ts deleted file mode 100644 index c706ca6..0000000 --- a/src/server/features/psi/services/PsiIssuesService.ts +++ /dev/null @@ -1,230 +0,0 @@ -import { sortBy } from "remeda"; -import { z } from "zod"; -import { jsonCodec } from "@/shared/json"; - -const PSI_CATEGORIES = [ - "performance", - "accessibility", - "best-practices", - "seo", -] as const; - -export type PsiIssueCategory = (typeof PSI_CATEGORIES)[number]; - -type PsiIssue = { - category: PsiIssueCategory; - auditKey: string; - title: string; - description: string; - score: number | null; - scoreDisplayMode: string | null; - displayValue: string | null; - impactMs: number | null; - impactBytes: number | null; - severity: "critical" | "warning" | "info"; - items: string[]; -}; - -type LighthouseAudit = { - title?: string; - description?: string; - score?: number | null; - scoreDisplayMode?: string; - displayValue?: string; - details?: { - overallSavingsMs?: number; - overallSavingsBytes?: number; - items?: Array>; - }; -}; - -type LighthouseCategory = { - auditRefs?: Array<{ - id?: string; - }>; -}; - -const lighthouseAuditSchema = z.object({ - title: z.string().optional(), - description: z.string().optional(), - score: z.number().nullable().optional(), - scoreDisplayMode: z.string().optional(), - displayValue: z.string().optional(), - details: z - .object({ - overallSavingsMs: z.number().optional(), - overallSavingsBytes: z.number().optional(), - items: z.array(z.record(z.string(), z.unknown())).optional(), - }) - .optional(), -}); - -const lighthouseCategorySchema = z.object({ - auditRefs: z - .array( - z.object({ - id: z.string().optional(), - }), - ) - .optional(), -}); - -const psiPayloadSchema = z.object({ - lighthouseResult: z - .object({ - audits: z - .record(z.string(), lighthouseAuditSchema) - .optional() - .default({}), - categories: z - .record(z.string(), lighthouseCategorySchema) - .optional() - .default({}), - }) - .optional() - .default({ - audits: {}, - categories: {}, - }), -}); - -const psiPayloadCodec = jsonCodec(psiPayloadSchema); - -function normalizeScore(score: number | null | undefined): number | null { - if (score == null || Number.isNaN(score)) return null; - return Math.round(score * 100); -} - -function compactItem(item: Record): string { - const preferredKeys = [ - "url", - "source", - "nodeLabel", - "snippet", - "totalBytes", - "wastedBytes", - "wastedMs", - "label", - "value", - ]; - - const output: Record = {}; - for (const key of preferredKeys) { - if (item[key] != null) { - output[key] = item[key]; - } - } - - if (Object.keys(output).length === 0) { - for (const [key, value] of Object.entries(item).slice(0, 6)) { - output[key] = value; - } - } - - return JSON.stringify(output); -} - -function getSeverity(input: { - score: number | null; - impactMs: number | null; - impactBytes: number | null; -}): "critical" | "warning" | "info" { - if ((input.impactMs ?? 0) >= 300 || (input.impactBytes ?? 0) >= 150_000) { - return "critical"; - } - - if (input.score != null && input.score < 50) { - return "critical"; - } - - if ((input.impactMs ?? 0) >= 100 || (input.impactBytes ?? 0) >= 50_000) { - return "warning"; - } - - if (input.score != null && input.score < 90) { - return "warning"; - } - - return "info"; -} - -function parseIssues( - payloadJson: string, - categoryFilter?: PsiIssueCategory, -): PsiIssue[] { - const parsedPayload = psiPayloadCodec.safeParse(payloadJson); - if (!parsedPayload.success) { - throw new Error("Invalid Lighthouse payload JSON"); - } - - const audits: Record = - parsedPayload.data.lighthouseResult.audits; - const categories: Record = - parsedPayload.data.lighthouseResult.categories; - - const issues: PsiIssue[] = []; - - for (const category of PSI_CATEGORIES) { - if (categoryFilter && category !== categoryFilter) continue; - - const refs = categories[category]?.auditRefs ?? []; - for (const ref of refs) { - const auditKey = ref.id; - if (!auditKey) continue; - - const audit = audits[auditKey]; - if (!audit) continue; - - const score = normalizeScore(audit.score); - const displayMode = audit.scoreDisplayMode ?? null; - - const isPass = - (score != null && score >= 90) || - displayMode === "notApplicable" || - displayMode === "informative" || - displayMode === "manual"; - - if (isPass) continue; - - const impactMs = - typeof audit.details?.overallSavingsMs === "number" - ? audit.details.overallSavingsMs - : null; - const impactBytes = - typeof audit.details?.overallSavingsBytes === "number" - ? audit.details.overallSavingsBytes - : null; - - const items = Array.isArray(audit.details?.items) - ? audit.details.items.slice(0, 10).map(compactItem) - : []; - - issues.push({ - category, - auditKey, - title: audit.title ?? auditKey, - description: audit.description ?? "", - score, - scoreDisplayMode: displayMode, - displayValue: audit.displayValue ?? null, - impactMs, - impactBytes, - severity: getSeverity({ score, impactMs, impactBytes }), - items, - }); - } - } - - return sortBy( - issues, - [ - (issue) => (issue.impactMs ?? 0) * 1000 + (issue.impactBytes ?? 0), - "desc", - ], - [(issue) => issue.score ?? 100, "asc"], - ); -} - -export const PsiIssuesService = { - parseIssues, -} as const; diff --git a/src/server/features/psi/services/psi-export.ts b/src/server/features/psi/services/psi-export.ts deleted file mode 100644 index e19297e..0000000 --- a/src/server/features/psi/services/psi-export.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { - PsiIssuesService, - type PsiIssueCategory, -} from "@/server/features/psi/services/PsiIssuesService"; - -type PsiStrategy = "mobile" | "desktop"; -type ExportMode = "full" | "issues" | "category"; - -export function buildPsiExportFile(input: { - idField: "auditId" | "resultId"; - idValue: string; - finalUrl: string; - strategy: PsiStrategy; - createdAt: string; - payloadJson: string; - mode: ExportMode; - category?: PsiIssueCategory; -}) { - const safeDate = input.createdAt.replace(/[:.]/g, "-"); - const baseName = `psi-${input.strategy}-${safeDate}`; - - if (input.mode === "full") { - return { - filename: `${baseName}-full.json`, - content: input.payloadJson, - }; - } - - const issues = PsiIssuesService.parseIssues( - input.payloadJson, - input.category, - ); - - return { - filename: - input.mode === "category" && input.category - ? `${baseName}-${input.category}-issues.json` - : `${baseName}-issues.json`, - content: JSON.stringify( - { - [input.idField]: input.idValue, - finalUrl: input.finalUrl, - strategy: input.strategy, - createdAt: input.createdAt, - category: input.category ?? "all", - issues, - }, - null, - 2, - ), - }; -} diff --git a/src/server/lib/audit/lighthouse.ts b/src/server/lib/audit/lighthouse.ts new file mode 100644 index 0000000..1db8975 --- /dev/null +++ b/src/server/lib/audit/lighthouse.ts @@ -0,0 +1,163 @@ +import { detectUrlTemplate } from "./url-utils"; +import type { BillingCustomerContext } from "@/server/billing/subscription"; +import { createDataforseoClient } from "@/server/lib/dataforseoClient"; +import type { LighthouseResult, LighthouseStrategy } from "./types"; +import { putTextToR2 } from "@/server/lib/r2"; + +interface LighthouseSamplePage { + url: string; + statusCode: number; +} + +type LighthouseFetchResult = { + result: LighthouseResult; + payloadJson: string | null; +}; + +async function fetchLighthouseResult( + url: string, + pageId: string, + strategy: "mobile" | "desktop", + billingCustomer: BillingCustomerContext, +): Promise { + let lastError: Error | null = null; + const dataforseo = createDataforseoClient(billingCustomer); + + for (let attempt = 0; attempt < 3; attempt++) { + try { + if (attempt > 0) { + // Exponential backoff: 2s, 4s + await new Promise((resolve) => + setTimeout(resolve, 2000 * Math.pow(2, attempt - 1)), + ); + } + + const data = await dataforseo.lighthouse.live({ url, strategy }); + + return { + result: { + url, + pageId, + strategy, + performanceScore: data.scores.performance, + accessibilityScore: data.scores.accessibility, + bestPracticesScore: data.scores["best-practices"], + seoScore: data.scores.seo, + lcpMs: data.metrics.largestContentfulPaint.numericValue, + cls: data.metrics.cumulativeLayoutShift.numericValue, + inpMs: data.metrics.interactionToNextPaint.numericValue, + ttfbMs: data.metrics.serverResponseTime.numericValue, + }, + payloadJson: JSON.stringify(data), + }; + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + console.warn( + `Lighthouse attempt ${attempt + 1} failed for ${url}:`, + lastError.message, + ); + } + } + + // All retries exhausted — return null scores + console.error( + `Lighthouse failed after 3 attempts for ${url}:`, + lastError?.message, + ); + return { + result: { + url, + pageId, + strategy, + performanceScore: null, + accessibilityScore: null, + bestPracticesScore: null, + seoScore: null, + lcpMs: null, + cls: null, + inpMs: null, + ttfbMs: null, + errorMessage: lastError?.message ?? "Lighthouse request failed", + }, + payloadJson: null, + }; +} + +export async function fetchAndStoreLighthouseResult(input: { + url: string; + pageId: string; + strategy: "mobile" | "desktop"; + billingCustomer: BillingCustomerContext; + projectId: string; + auditId: string; +}): Promise { + const fetched = await fetchLighthouseResult( + input.url, + input.pageId, + input.strategy, + input.billingCustomer, + ); + + if (!fetched.payloadJson) { + return fetched.result; + } + + const key = `site-audit/${input.projectId}/${input.auditId}/${input.pageId}-${input.strategy}.json`; + const uploaded = await putTextToR2(key, fetched.payloadJson); + + return { + ...fetched.result, + r2Key: uploaded.key, + payloadSizeBytes: uploaded.sizeBytes, + }; +} + +/** + * Select which pages to run Lighthouse on, based on the chosen strategy. + */ +export function selectLighthouseSample( + pages: LighthouseSamplePage[], + startUrl: string, + strategy: LighthouseStrategy, +): string[] { + if (strategy === "none") return []; + + // Only consider pages that loaded successfully + const validPages = pages.filter( + (p) => p.statusCode >= 200 && p.statusCode < 300, + ); + + if (strategy === "all") { + return validPages.map((p) => p.url); + } + + if (strategy === "manual") { + // manual = user picks after crawl; for now return empty + return []; + } + + // strategy === "auto": homepage + 1 per URL pattern, capped at 10 + const selected = new Set(); + + // Always include the start URL / homepage + const startPage = validPages.find((p) => p.url === startUrl); + if (startPage) selected.add(startPage.url); + + // Group by URL template pattern + const templateGroups = new Map(); + for (const page of validPages) { + if (selected.has(page.url)) continue; + const template = detectUrlTemplate(new URL(page.url).pathname); + if (!templateGroups.has(template)) { + templateGroups.set(template, page); + } + } + + // Add one page per template group + for (const [, page] of templateGroups) { + if (selected.size >= 10) break; + selected.add(page.url); + } + + return Array.from(selected); +} diff --git a/src/server/lib/audit/psi.ts b/src/server/lib/audit/psi.ts deleted file mode 100644 index 3bef211..0000000 --- a/src/server/lib/audit/psi.ts +++ /dev/null @@ -1,176 +0,0 @@ -/** - * Google PageSpeed Insights (PSI) API client and sampling logic. - */ -import { detectUrlTemplate } from "./url-utils"; -import type { PsiResult, PsiStrategy } from "./types"; - -interface PsiSamplePage { - url: string; - statusCode: number; -} - -const PSI_API_URL = - "https://www.googleapis.com/pagespeedonline/v5/runPagespeed"; - -/** - * Fetch PageSpeed Insights results for a single URL. - * Retries up to 3 times with exponential backoff. - */ -export async function fetchPsiResult( - url: string, - pageId: string, - strategy: "mobile" | "desktop", - apiKey: string, -): Promise { - // Build URL with multiple category params (PSI API allows repeated 'category') - const apiUrl = `${PSI_API_URL}?url=${encodeURIComponent(url)}&strategy=${strategy}&key=${encodeURIComponent(apiKey)}&category=performance&category=accessibility&category=best-practices&category=seo`; - - let lastError: Error | null = null; - - for (let attempt = 0; attempt < 3; attempt++) { - try { - if (attempt > 0) { - // Exponential backoff: 2s, 4s - await new Promise((resolve) => - setTimeout(resolve, 2000 * Math.pow(2, attempt - 1)), - ); - } - - const response = await fetch(apiUrl, { - signal: AbortSignal.timeout(60_000), // PSI can be slow - }); - - if (!response.ok) { - const text = await response.text(); - throw new Error(`PSI API ${response.status}: ${text.slice(0, 200)}`); - } - - const data: PsiApiResponse = await response.json(); - - return parsePsiResponse(data, url, pageId, strategy); - } catch (error) { - lastError = error instanceof Error ? error : new Error(String(error)); - console.warn( - `PSI attempt ${attempt + 1} failed for ${url}:`, - lastError.message, - ); - } - } - - // All retries exhausted — return null scores - console.error(`PSI failed after 3 attempts for ${url}:`, lastError?.message); - return { - url, - pageId, - strategy, - performanceScore: null, - accessibilityScore: null, - bestPracticesScore: null, - seoScore: null, - lcpMs: null, - cls: null, - inpMs: null, - ttfbMs: null, - errorMessage: lastError?.message ?? "PSI request failed", - }; -} - -/** - * Select which pages to run PSI on, based on the chosen strategy. - */ -export function selectPsiSample( - pages: PsiSamplePage[], - startUrl: string, - strategy: PsiStrategy, -): string[] { - if (strategy === "none") return []; - - // Only consider pages that loaded successfully - const validPages = pages.filter( - (p) => p.statusCode >= 200 && p.statusCode < 300, - ); - - if (strategy === "all") { - return validPages.map((p) => p.url); - } - - if (strategy === "manual") { - // manual = user picks after crawl; for now return empty - return []; - } - - // strategy === "auto": homepage + 1 per URL pattern, capped at 10 - const selected = new Set(); - - // Always include the start URL / homepage - const startPage = validPages.find((p) => p.url === startUrl); - if (startPage) selected.add(startPage.url); - - // Group by URL template pattern - const templateGroups = new Map(); - for (const page of validPages) { - if (selected.has(page.url)) continue; - const template = detectUrlTemplate(new URL(page.url).pathname); - if (!templateGroups.has(template)) { - templateGroups.set(template, page); - } - } - - // Add one page per template group - for (const [, page] of templateGroups) { - if (selected.size >= 10) break; - selected.add(page.url); - } - - return Array.from(selected); -} - -// ─── PSI API Response Types ────────────────────────────────────────────────── - -interface PsiApiResponse { - lighthouseResult?: { - categories?: { - performance?: { score?: number | null }; - accessibility?: { score?: number | null }; - "best-practices"?: { score?: number | null }; - seo?: { score?: number | null }; - }; - audits?: { - "largest-contentful-paint"?: { numericValue?: number }; - "cumulative-layout-shift"?: { numericValue?: number }; - "interaction-to-next-paint"?: { numericValue?: number }; - "server-response-time"?: { numericValue?: number }; - }; - }; -} - -function parsePsiResponse( - data: PsiApiResponse, - url: string, - pageId: string, - strategy: "mobile" | "desktop", -): PsiResult { - const categories = data.lighthouseResult?.categories; - const audits = data.lighthouseResult?.audits; - - return { - url, - pageId, - strategy, - performanceScore: scoreToPercent(categories?.performance?.score), - accessibilityScore: scoreToPercent(categories?.accessibility?.score), - bestPracticesScore: scoreToPercent(categories?.["best-practices"]?.score), - seoScore: scoreToPercent(categories?.seo?.score), - lcpMs: audits?.["largest-contentful-paint"]?.numericValue ?? null, - cls: audits?.["cumulative-layout-shift"]?.numericValue ?? null, - inpMs: audits?.["interaction-to-next-paint"]?.numericValue ?? null, - ttfbMs: audits?.["server-response-time"]?.numericValue ?? null, - rawPayloadJson: JSON.stringify(data), - }; -} - -/** PSI scores come as 0-1 floats; convert to 0-100 integers. */ -function scoreToPercent(score: number | null | undefined): number | null { - if (score == null) return null; - return Math.round(score * 100); -} diff --git a/src/server/lib/audit/types.ts b/src/server/lib/audit/types.ts index eb3470a..4aab81d 100644 --- a/src/server/lib/audit/types.ts +++ b/src/server/lib/audit/types.ts @@ -2,12 +2,27 @@ * Shared types for the site audit system. */ -export type PsiStrategy = "auto" | "all" | "manual" | "none"; +import { z } from "zod"; +import { jsonCodec } from "@/shared/json"; + +export type LighthouseStrategy = "auto" | "all" | "manual" | "none"; export interface AuditConfig { maxPages: number; - psiStrategy: PsiStrategy; - psiApiKey?: string; + lighthouseStrategy: LighthouseStrategy; +} + +const auditConfigSchema = z.object({ + maxPages: z.number().int().min(10).max(10_000), + lighthouseStrategy: z.enum(["auto", "all", "manual", "none"]), +}); + +const auditConfigCodec = jsonCodec(auditConfigSchema); + +export function parseAuditConfig(configRaw: string | null): AuditConfig | null { + if (!configRaw) return null; + const result = auditConfigCodec.safeParse(configRaw); + return result.success ? result.data : null; } /** Data extracted from a single page via cheerio. */ @@ -47,8 +62,8 @@ export interface PageAnalysis { hreflangTags: string[]; } -/** PSI result for a single URL+strategy. */ -export interface PsiResult { +/** Lighthouse result for a single URL+strategy. */ +export interface LighthouseResult { url: string; pageId: string; strategy: "mobile" | "desktop"; @@ -63,5 +78,35 @@ export interface PsiResult { errorMessage?: string | null; r2Key?: string | null; payloadSizeBytes?: number | null; - rawPayloadJson?: string | null; +} + +export interface StepPageResult { + id: string; + url: string; + statusCode: number; + redirectUrl: string | null; + title: string; + metaDescription: string; + canonicalUrl: string | null; + robotsMeta: string | null; + ogTitle: string | null; + ogDescription: string | null; + ogImage: string | null; + h1Count: number; + h2Count: number; + h3Count: number; + h4Count: number; + h5Count: number; + h6Count: number; + headingOrder: number[]; + wordCount: number; + imagesTotal: number; + imagesMissingAlt: number; + images: Array<{ src: string | null; alt: string | null }>; + internalLinks: string[]; + externalLinks: string[]; + hasStructuredData: boolean; + hreflangTags: string[]; + isIndexable: boolean; + responseTimeMs: number; } diff --git a/src/server/lib/dataforseoClient.ts b/src/server/lib/dataforseoClient.ts index f6ef037..7d9c823 100644 --- a/src/server/lib/dataforseoClient.ts +++ b/src/server/lib/dataforseoClient.ts @@ -18,6 +18,9 @@ import { type LabsKeywordDataItem, type SerpLiveItem, } from "@/server/lib/dataforseo"; +import { fetchDataforseoLighthouseResultRaw } from "@/server/lib/dataforseoLighthouse"; +import type { LighthouseStrategy } from "@/server/lib/dataforseoLighthousePayload"; +import type { StoredLighthousePayload } from "@/server/lib/lighthouseStoredPayload"; import { fetchBacklinksRowsRaw, fetchBacklinksSummaryRaw, @@ -166,6 +169,13 @@ export function createDataforseoClient(customer: BillingCustomerContext) { ); }, }, + lighthouse: { + live(input: { url: string; strategy: LighthouseStrategy }) { + return meterDataforseoCall(customer, () => + fetchDataforseoLighthouseResultRaw(input), + ); + }, + }, } as const; } diff --git a/src/server/lib/dataforseoLighthouse.ts b/src/server/lib/dataforseoLighthouse.ts new file mode 100644 index 0000000..185c203 --- /dev/null +++ b/src/server/lib/dataforseoLighthouse.ts @@ -0,0 +1,60 @@ +import { env } from "cloudflare:workers"; +import { + parseDataforseoLighthousePayload, + requestCategories, + type LighthouseStrategy, +} from "@/server/lib/dataforseoLighthousePayload"; +import type { DataforseoApiResponse } from "@/server/lib/dataforseoCost"; +import type { StoredLighthousePayload } from "@/server/lib/lighthouseStoredPayload"; + +const DATAFORSEO_LIGHTHOUSE_ENDPOINT = + "https://api.dataforseo.com/v3/on_page/lighthouse/live/json"; + +export async function fetchDataforseoLighthouseResultRaw(input: { + url: string; + strategy: LighthouseStrategy; +}): Promise> { + const response = await fetch(DATAFORSEO_LIGHTHOUSE_ENDPOINT, { + method: "POST", + headers: { + Authorization: `Basic ${env.DATAFORSEO_API_KEY?.trim() ?? ""}`, + "Content-Type": "application/json", + }, + body: JSON.stringify([ + { + url: input.url, + for_mobile: input.strategy === "mobile", + categories: requestCategories, + }, + ]), + signal: AbortSignal.timeout(60_000), + }); + + const rawText = await response.text(); + + if (!response.ok) { + throw new Error( + `DataForSEO Lighthouse request failed (${response.status}): ${rawText}`, + ); + } + + let payload: unknown; + try { + payload = JSON.parse(rawText); + } catch { + throw new Error( + `DataForSEO Lighthouse returned non-JSON content (content-type: ${response.headers.get("content-type") ?? "unknown"}): ${rawText}`, + ); + } + + const data = parseDataforseoLighthousePayload(payload, input); + + return { + data, + billing: { + path: ["v3", "on_page", "lighthouse", "live", "json"], + costUsd: data.metadata.cost ?? 0, + resultCount: 1, + }, + }; +} diff --git a/src/server/lib/dataforseoLighthousePayload.test.ts b/src/server/lib/dataforseoLighthousePayload.test.ts new file mode 100644 index 0000000..bbe0f1c --- /dev/null +++ b/src/server/lib/dataforseoLighthousePayload.test.ts @@ -0,0 +1,250 @@ +import { describe, expect, it } from "vitest"; +import { parseDataforseoLighthousePayload } from "@/server/lib/dataforseoLighthousePayload"; +import { readStoredLighthousePayload } from "@/server/lib/lighthousePayload"; + +describe("parseDataforseoLighthousePayload", () => { + it("stores only issue-level lighthouse data and key metadata", () => { + const parsed = parseDataforseoLighthousePayload( + { + status_code: 20000, + status_message: "Ok.", + tasks: [ + { + id: "task-1", + status_code: 20000, + status_message: "Ok.", + cost: 0.00425, + result: [ + { + requestedUrl: "https://everyapp.dev/", + finalUrl: "https://everyapp.dev/", + lighthouseVersion: "12.2.0", + categories: { + performance: { + score: 0.54, + auditRefs: [{ id: "unused-javascript" }], + }, + accessibility: { + score: 0.93, + auditRefs: [{ id: "accesskeys" }], + }, + "best-practices": { score: 0.79, auditRefs: [] }, + seo: { score: 0.92, auditRefs: [] }, + }, + audits: { + "unused-javascript": { + title: "Reduce unused JavaScript", + description: "Trim dead code.", + score: 0, + scoreDisplayMode: "metricSavings", + displayValue: "Potential savings of 188 KiB", + numericValue: 193002, + details: { + overallSavingsMs: 1270, + overallSavingsBytes: 193002, + items: [ + { + url: "https://cdn.example.com/app.js", + wastedBytes: 193002, + }, + ], + }, + }, + accesskeys: { + title: "`[accesskey]` values are unique", + description: "Access keys should not conflict.", + score: null, + scoreDisplayMode: "error", + }, + interactive: { + title: "Time to Interactive", + description: "Time until the page becomes interactive.", + score: 0.13, + scoreDisplayMode: "numeric", + displayValue: "12.8 s", + numericValue: 12800, + }, + }, + }, + ], + }, + ], + }, + { + url: "https://everyapp.dev/", + strategy: "mobile", + }, + ); + + const { report } = readStoredLighthousePayload(JSON.stringify(parsed)); + + expect(parsed.metrics.timeToInteractive.displayValue).toBe("12.8 s"); + expect(parsed).toMatchObject({ + version: 2, + source: "dataforseo-lighthouse", + hasIssueDetails: true, + metadata: { + requestedUrl: "https://everyapp.dev/", + finalUrl: "https://everyapp.dev/", + strategy: "mobile", + lighthouseVersion: "12.2.0", + taskId: "task-1", + cost: 0.00425, + }, + scores: { + performance: 54, + accessibility: 93, + "best-practices": 79, + seo: 92, + }, + metrics: { + timeToInteractive: { + score: 13, + displayValue: "12.8 s", + numericValue: 12800, + }, + }, + }); + expect(parsed.issues).toHaveLength(1); + expect(parsed.issues).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ auditKey: "interactive" }), + ]), + ); + expect(parsed).not.toHaveProperty("lighthouseResult"); + + expect(report.hasIssueDetails).toBe(true); + expect(report.issues).toEqual([ + expect.objectContaining({ + auditKey: "unused-javascript", + category: "performance", + impactMs: 1270, + impactBytes: 193002, + title: "Reduce unused JavaScript", + }), + ]); + }); + + it("throws when the lighthouse response has no category scores", () => { + expect(() => + parseDataforseoLighthousePayload( + { + status_code: 20000, + status_message: "Ok.", + tasks: [ + { + id: "task-1", + status_code: 20000, + status_message: "Ok.", + cost: 0.00425, + result: [ + { + requestedUrl: + "https://everyapp.dev/blog/category/cyber-security", + finalUrl: + "https://everyapp.dev/blog/category/cyber-security/", + lighthouseVersion: "12.2.0", + categories: { + performance: { score: null, auditRefs: [] }, + accessibility: { score: null, auditRefs: [] }, + "best-practices": { score: null, auditRefs: [] }, + seo: { score: null, auditRefs: [] }, + }, + audits: {}, + }, + ], + }, + ], + }, + { + url: "https://everyapp.dev/blog/category/cyber-security", + strategy: "desktop", + }, + ), + ).toThrow("DataForSEO Lighthouse returned no category scores"); + }); + + it("throws when DataForSEO returns a non-success task status", () => { + expect(() => + parseDataforseoLighthousePayload( + { + status_code: 20000, + status_message: "Ok.", + tasks: [ + { + id: "task-1", + status_code: 40501, + status_message: "Insufficient credits", + result: [], + }, + ], + }, + { + url: "https://everyapp.dev/", + strategy: "mobile", + }, + ), + ).toThrow("Insufficient credits"); + }); + + it("includes schema details when the payload shape is invalid", () => { + expect(() => + parseDataforseoLighthousePayload(null, { + url: "https://everyapp.dev/", + strategy: "mobile", + }), + ).toThrow(""); + }); + + it("accepts audits whose details.items is an object", () => { + expect(() => + parseDataforseoLighthousePayload( + { + status_code: 20000, + status_message: "Ok.", + tasks: [ + { + id: "task-1", + status_code: 20000, + status_message: "Ok.", + cost: 0.00425, + result: [ + { + requestedUrl: "https://everyapp.dev/", + finalUrl: "https://everyapp.dev/", + lighthouseVersion: "12.2.0", + categories: { + performance: { + score: 0.54, + auditRefs: [{ id: "document-latency-insight" }], + }, + accessibility: { score: 0.93, auditRefs: [] }, + "best-practices": { score: 0.79, auditRefs: [] }, + seo: { score: 0.92, auditRefs: [] }, + }, + audits: { + "document-latency-insight": { + title: "Document request latency", + description: "Latency insight.", + score: 0, + scoreDisplayMode: "informative", + details: { + items: { + latencyMs: 120, + }, + }, + }, + }, + }, + ], + }, + ], + }, + { + url: "https://everyapp.dev/", + strategy: "mobile", + }, + ), + ).not.toThrow(); + }); +}); diff --git a/src/server/lib/dataforseoLighthousePayload.ts b/src/server/lib/dataforseoLighthousePayload.ts new file mode 100644 index 0000000..edacd1e --- /dev/null +++ b/src/server/lib/dataforseoLighthousePayload.ts @@ -0,0 +1,172 @@ +import { z } from "zod"; +import { + buildStoredLighthouseIssues, + buildStoredLighthouseMetrics, + type RawLighthouseAudit, + type RawLighthouseCategory, + scoreToPercent, + type StoredLighthousePayload, +} from "@/server/lib/lighthouseStoredPayload"; + +export const requestCategories = [ + "performance", + "accessibility", + "best_practices", + "seo", +] as const; + +export type LighthouseStrategy = "mobile" | "desktop"; + +const lighthouseAuditItemsSchema = z + .union([ + z.array(z.record(z.string(), z.unknown())), + z.record(z.string(), z.unknown()), + ]) + .transform((items) => (Array.isArray(items) ? items : [items])); + +const lighthouseAuditSchema = z + .object({ + score: z.number().nullable().optional(), + displayValue: z.string().optional(), + numericValue: z.number().optional(), + title: z.string().optional(), + description: z.string().optional(), + scoreDisplayMode: z.string().optional(), + details: z + .object({ + overallSavingsMs: z.number().optional(), + overallSavingsBytes: z.number().optional(), + items: lighthouseAuditItemsSchema.optional(), + }) + .passthrough() + .optional(), + }) + .passthrough(); + +const lighthouseCategorySchema = z + .object({ + score: z.number().nullable().optional(), + auditRefs: z + .array( + z + .object({ + id: z.string().optional(), + }) + .passthrough(), + ) + .optional(), + }) + .passthrough(); + +const lighthouseResponseSchema = z + .object({ + requestedUrl: z.string().optional(), + finalUrl: z.string().optional(), + lighthouseVersion: z.string().optional(), + categories: z + .record(z.string(), lighthouseCategorySchema) + .optional() + .default({}), + audits: z.record(z.string(), lighthouseAuditSchema).optional().default({}), + }) + .passthrough(); + +const dataforseoTaskSchema = z + .object({ + id: z.string().optional(), + cost: z.number().optional(), + status_code: z.number().optional(), + status_message: z.string().optional(), + result: z.array(lighthouseResponseSchema).optional(), + }) + .passthrough(); + +const dataforseoLighthouseResponseSchema = z + .object({ + status_code: z.number().optional(), + status_message: z.string().optional(), + tasks: z.array(dataforseoTaskSchema).optional(), + }) + .passthrough(); + +function summarizeZodIssues(error: z.ZodError, maxIssues = 3): string { + return error.issues + .slice(0, maxIssues) + .map((issue) => { + const path = issue.path.length > 0 ? issue.path.join(".") : ""; + return `${path}: ${issue.message}`; + }) + .join("; "); +} + +export function parseDataforseoLighthousePayload( + payload: unknown, + input: { url: string; strategy: LighthouseStrategy }, +): StoredLighthousePayload { + const parsed = dataforseoLighthouseResponseSchema.safeParse(payload); + if (!parsed.success) { + throw new Error( + `DataForSEO Lighthouse returned an invalid response: ${summarizeZodIssues(parsed.error)}`, + ); + } + + if (parsed.data.status_code !== 20000) { + throw new Error( + parsed.data.status_message ?? "DataForSEO Lighthouse request failed", + ); + } + + const task = parsed.data.tasks?.[0]; + if (!task) { + throw new Error("DataForSEO Lighthouse response missing task"); + } + + if (task.status_code !== 20000) { + throw new Error(task.status_message ?? "DataForSEO Lighthouse task failed"); + } + + const result = task.result?.[0]; + if (!result) { + throw new Error("DataForSEO Lighthouse response missing result"); + } + + const fetchedAt = new Date().toISOString(); + const categories: Record = + result.categories ?? {}; + const audits: Record = result.audits ?? {}; + const issueReport = buildStoredLighthouseIssues({ audits, categories }); + const metrics = buildStoredLighthouseMetrics({ audits }); + const storedPayload: StoredLighthousePayload = { + version: 2, + source: "dataforseo-lighthouse", + hasIssueDetails: issueReport.hasIssueDetails, + metadata: { + requestedUrl: result.requestedUrl ?? input.url, + finalUrl: result.finalUrl ?? input.url, + strategy: input.strategy, + fetchedAt, + lighthouseVersion: result.lighthouseVersion ?? null, + taskId: task.id ?? null, + cost: task.cost ?? null, + }, + scores: { + performance: scoreToPercent(categories.performance?.score), + accessibility: scoreToPercent(categories.accessibility?.score), + "best-practices": scoreToPercent(categories["best-practices"]?.score), + seo: scoreToPercent(categories.seo?.score), + }, + metrics, + issues: issueReport.issues, + }; + + const allScoresMissing = Object.values(storedPayload.scores).every( + (score) => score == null, + ); + if (allScoresMissing) { + throw new Error( + `DataForSEO Lighthouse returned no category scores for ${storedPayload.metadata.finalUrl}`, + ); + } + + return storedPayload; +} diff --git a/src/server/lib/lighthousePayload.ts b/src/server/lib/lighthousePayload.ts new file mode 100644 index 0000000..9e6d757 --- /dev/null +++ b/src/server/lib/lighthousePayload.ts @@ -0,0 +1,123 @@ +import { sortBy } from "remeda"; +import type { LighthouseCategory } from "@/shared/lighthouse"; +import { jsonCodec } from "@/shared/json"; +import { + storedLighthousePayloadSchema, + type StoredLighthouseIssue, + type StoredLighthousePayload, +} from "@/server/lib/lighthouseStoredPayload"; + +const storedPayloadCodec = jsonCodec(storedLighthousePayloadSchema); + +type ExportMode = "full" | "issues" | "category"; + +type LighthouseIssueReport = { + issues: StoredLighthouseIssue[]; + hasIssueDetails: boolean; +}; + +function sortIssues(issues: StoredLighthouseIssue[]) { + return sortBy( + issues, + [ + (issue) => (issue.impactMs ?? 0) * 1000 + (issue.impactBytes ?? 0), + "desc", + ], + [(issue) => issue.score ?? 100, "asc"], + ); +} + +function parseStoredLighthousePayload( + payloadJson: string, +): StoredLighthousePayload | null { + const storedPayload = storedPayloadCodec.safeParse(payloadJson); + if (storedPayload.success) { + return storedPayload.data; + } + + try { + JSON.parse(payloadJson); + } catch { + throw new Error("Invalid Lighthouse payload JSON"); + } + + return null; +} + +function buildLighthouseIssueReport( + storedPayload: StoredLighthousePayload | null, + categoryFilter?: LighthouseCategory, +): LighthouseIssueReport { + if (!storedPayload) { + return { + hasIssueDetails: false, + issues: [], + }; + } + + const filteredIssues = categoryFilter + ? storedPayload.issues.filter((issue) => issue.category === categoryFilter) + : storedPayload.issues; + + return { + hasIssueDetails: storedPayload.hasIssueDetails, + issues: sortIssues(filteredIssues), + }; +} + +export function readStoredLighthousePayload( + payloadJson: string, + categoryFilter?: LighthouseCategory, +) { + const storedPayload = parseStoredLighthousePayload(payloadJson); + + return { + storedPayload, + report: buildLighthouseIssueReport(storedPayload, categoryFilter), + }; +} + +export function buildLighthouseExportFile(input: { + idField: "auditId" | "resultId"; + idValue: string; + finalUrl: string; + strategy: "mobile" | "desktop"; + createdAt: string; + payloadJson: string; + mode: ExportMode; + category?: LighthouseCategory; +}) { + const safeDate = input.createdAt.replace(/[:.]/g, "-"); + const baseName = `lighthouse-${input.strategy}-${safeDate}`; + + if (input.mode === "full") { + return { + filename: `${baseName}-payload.json`, + content: input.payloadJson, + }; + } + + const { report } = readStoredLighthousePayload( + input.payloadJson, + input.category, + ); + + return { + filename: + input.mode === "category" && input.category + ? `${baseName}-${input.category}-issues.json` + : `${baseName}-issues.json`, + content: JSON.stringify( + { + [input.idField]: input.idValue, + finalUrl: input.finalUrl, + strategy: input.strategy, + createdAt: input.createdAt, + category: input.category ?? "all", + issues: report.issues, + }, + null, + 2, + ), + }; +} diff --git a/src/server/lib/lighthouseStoredPayload.test.ts b/src/server/lib/lighthouseStoredPayload.test.ts new file mode 100644 index 0000000..1b467ae --- /dev/null +++ b/src/server/lib/lighthouseStoredPayload.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from "vitest"; +import { + buildStoredLighthouseIssues, + buildStoredLighthouseMetrics, +} from "@/server/lib/lighthouseStoredPayload"; + +describe("lighthouse stored payload classification", () => { + it("keeps actionable audits but separates metrics and diagnostics", () => { + const audits = { + interactive: { + title: "Time to Interactive", + score: 0.13, + scoreDisplayMode: "numeric", + displayValue: "12.8 s", + numericValue: 12800, + }, + "largest-contentful-paint-element": { + title: "Largest Contentful Paint element", + score: 0, + scoreDisplayMode: "metricSavings", + displayValue: "3,630 ms", + }, + "unused-javascript": { + title: "Reduce unused JavaScript", + description: "Trim dead code.", + score: 0.5, + scoreDisplayMode: "metricSavings", + displayValue: "Potential savings of 227 KiB", + details: { + overallSavingsBytes: 232886, + }, + }, + "color-contrast": { + title: + "Background and foreground colors do not have a sufficient contrast ratio.", + description: "Improve contrast.", + score: 0, + scoreDisplayMode: "binary", + }, + }; + + const categories = { + performance: { + auditRefs: [ + { id: "interactive" }, + { id: "largest-contentful-paint-element" }, + { id: "unused-javascript" }, + ], + }, + accessibility: { + auditRefs: [{ id: "color-contrast" }], + }, + "best-practices": { auditRefs: [] }, + seo: { auditRefs: [] }, + }; + + const issues = buildStoredLighthouseIssues({ audits, categories }); + const metrics = buildStoredLighthouseMetrics({ audits }); + + expect(issues.issues.map((issue) => issue.auditKey)).toEqual([ + "unused-javascript", + "color-contrast", + ]); + expect(metrics.timeToInteractive.displayValue).toBe("12.8 s"); + expect(metrics.timeToInteractive.score).toBe(13); + }); + + it("skips passing and non-actionable audits even when they appear in audit refs", () => { + const audits = { + passBinary: { + title: "Serve images in next-gen formats", + score: 1, + scoreDisplayMode: "binary", + }, + informative: { + title: "User Timing marks and measures", + score: 0, + scoreDisplayMode: "informative", + }, + manual: { + title: "Structured data is valid", + score: 0, + scoreDisplayMode: "manual", + }, + notApplicable: { + title: "Uses optimized images", + score: 0, + scoreDisplayMode: "notApplicable", + }, + errorAudit: { + title: "`[accesskey]` values are unique", + score: null, + scoreDisplayMode: "error", + }, + goodScore: { + title: "Reduce unused CSS", + score: 0.96, + scoreDisplayMode: "metricSavings", + }, + }; + + const categories = { + performance: { + auditRefs: [ + { id: "passBinary" }, + { id: "informative" }, + { id: "manual" }, + { id: "notApplicable" }, + { id: "goodScore" }, + ], + }, + accessibility: { + auditRefs: [{ id: "errorAudit" }], + }, + "best-practices": { auditRefs: [] }, + seo: { auditRefs: [] }, + }; + + const issues = buildStoredLighthouseIssues({ audits, categories }); + + expect(issues.hasIssueDetails).toBe(true); + expect(issues.issues).toEqual([]); + }); + + it("compacts affected items and caps them at ten entries", () => { + const items = Array.from({ length: 12 }, (_, index) => ({ + url: `https://cdn.example.com/script-${index}.js`, + wastedBytes: 1000 + index, + extraField: "ignored", + })); + + const issues = buildStoredLighthouseIssues({ + audits: { + "unused-javascript": { + title: "Reduce unused JavaScript", + description: "Trim dead code.", + score: 0, + scoreDisplayMode: "metricSavings", + details: { + overallSavingsBytes: 50000, + items, + }, + }, + }, + categories: { + performance: { auditRefs: [{ id: "unused-javascript" }] }, + accessibility: { auditRefs: [] }, + "best-practices": { auditRefs: [] }, + seo: { auditRefs: [] }, + }, + }); + + expect(issues.issues).toHaveLength(1); + expect(issues.issues[0]?.items).toHaveLength(10); + expect(issues.issues[0]?.items[0]).toBe( + '{"url":"https://cdn.example.com/script-0.js","wastedBytes":1000}', + ); + }); +}); diff --git a/src/server/lib/lighthouseStoredPayload.ts b/src/server/lib/lighthouseStoredPayload.ts new file mode 100644 index 0000000..744b0e5 --- /dev/null +++ b/src/server/lib/lighthouseStoredPayload.ts @@ -0,0 +1,310 @@ +import { z } from "zod"; +import { + LIGHTHOUSE_CATEGORIES, + type LighthouseCategory, +} from "@/shared/lighthouse"; + +export type StoredLighthouseIssue = { + category: LighthouseCategory; + auditKey: string; + title: string; + description: string; + score: number | null; + scoreDisplayMode: string | null; + displayValue: string | null; + impactMs: number | null; + impactBytes: number | null; + severity: "critical" | "warning" | "info"; + items: string[]; +}; + +type StoredLighthouseMetric = { + score: number | null; + displayValue: string | null; + numericValue: number | null; +}; + +export type StoredLighthouseMetrics = { + firstContentfulPaint: StoredLighthouseMetric; + largestContentfulPaint: StoredLighthouseMetric; + totalBlockingTime: StoredLighthouseMetric; + cumulativeLayoutShift: StoredLighthouseMetric; + speedIndex: StoredLighthouseMetric; + timeToInteractive: StoredLighthouseMetric; + interactionToNextPaint: StoredLighthouseMetric; + serverResponseTime: StoredLighthouseMetric; +}; + +export type StoredLighthousePayload = { + version: 2; + source: "dataforseo-lighthouse"; + hasIssueDetails: boolean; + metadata: { + requestedUrl: string; + finalUrl: string; + strategy: "mobile" | "desktop"; + fetchedAt: string; + lighthouseVersion: string | null; + taskId: string | null; + cost: number | null; + }; + scores: { + performance: number | null; + accessibility: number | null; + "best-practices": number | null; + seo: number | null; + }; + metrics: StoredLighthouseMetrics; + issues: StoredLighthouseIssue[]; +}; + +export type RawLighthouseAudit = { + title?: string; + description?: string; + score?: number | null; + scoreDisplayMode?: string; + displayValue?: string; + numericValue?: number; + details?: { + overallSavingsMs?: number; + overallSavingsBytes?: number; + items?: Array>; + }; +}; + +export type RawLighthouseCategory = { + score?: number | null; + auditRefs?: Array<{ + id?: string; + }>; +}; + +const storedLighthouseMetricSchema = z.object({ + score: z.number().nullable(), + displayValue: z.string().nullable(), + numericValue: z.number().nullable(), +}); + +export const storedLighthousePayloadSchema = z.object({ + version: z.literal(2), + source: z.literal("dataforseo-lighthouse"), + hasIssueDetails: z.boolean(), + metadata: z.object({ + requestedUrl: z.string(), + finalUrl: z.string(), + strategy: z.enum(["mobile", "desktop"]), + fetchedAt: z.string(), + lighthouseVersion: z.string().nullable(), + taskId: z.string().nullable(), + cost: z.number().nullable(), + }), + scores: z.object({ + performance: z.number().nullable(), + accessibility: z.number().nullable(), + "best-practices": z.number().nullable(), + seo: z.number().nullable(), + }), + metrics: z.object({ + firstContentfulPaint: storedLighthouseMetricSchema, + largestContentfulPaint: storedLighthouseMetricSchema, + totalBlockingTime: storedLighthouseMetricSchema, + cumulativeLayoutShift: storedLighthouseMetricSchema, + speedIndex: storedLighthouseMetricSchema, + timeToInteractive: storedLighthouseMetricSchema, + interactionToNextPaint: storedLighthouseMetricSchema, + serverResponseTime: storedLighthouseMetricSchema, + }), + issues: z.array( + z.object({ + category: z.enum(LIGHTHOUSE_CATEGORIES), + auditKey: z.string(), + title: z.string(), + description: z.string(), + score: z.number().nullable(), + scoreDisplayMode: z.string().nullable(), + displayValue: z.string().nullable(), + impactMs: z.number().nullable(), + impactBytes: z.number().nullable(), + severity: z.enum(["critical", "warning", "info"]), + items: z.array(z.string()), + }), + ), +}); + +export function scoreToPercent( + score: number | null | undefined, +): number | null { + if (score == null || Number.isNaN(score)) return null; + return Math.round(score * 100); +} + +function buildStoredMetric( + audit: RawLighthouseAudit | undefined, +): StoredLighthouseMetric { + return { + score: scoreToPercent(audit?.score), + displayValue: audit?.displayValue ?? null, + numericValue: + typeof audit?.numericValue === "number" ? audit.numericValue : null, + }; +} + +const DIAGNOSTIC_AUDIT_KEYS = new Set([ + "largest-contentful-paint-element", + "layout-shifts", + "diagnostics", + "metrics", + "network-requests", + "network-rtt", + "network-server-latency", + "main-thread-tasks", + "screenshot-thumbnails", + "final-screenshot", + "script-treemap-data", + "resource-summary", +]); + +function compactItem(item: Record): string { + const preferredKeys = [ + "url", + "source", + "nodeLabel", + "snippet", + "totalBytes", + "wastedBytes", + "wastedMs", + "label", + "value", + ]; + + const output: Record = {}; + for (const key of preferredKeys) { + if (item[key] != null) { + output[key] = item[key]; + } + } + + if (Object.keys(output).length === 0) { + for (const [key, value] of Object.entries(item).slice(0, 6)) { + output[key] = value; + } + } + + return JSON.stringify(output); +} + +function getSeverity(input: { + score: number | null; + impactMs: number | null; + impactBytes: number | null; +}): "critical" | "warning" | "info" { + if ((input.impactMs ?? 0) >= 300 || (input.impactBytes ?? 0) >= 150_000) { + return "critical"; + } + + if (input.score != null && input.score < 50) { + return "critical"; + } + + if ((input.impactMs ?? 0) >= 100 || (input.impactBytes ?? 0) >= 50_000) { + return "warning"; + } + + if (input.score != null && input.score < 90) { + return "warning"; + } + + return "info"; +} + +export function buildStoredLighthouseIssues(input: { + audits: Record; + categories: Record; +}) { + const hasIssueDetails = LIGHTHOUSE_CATEGORIES.some( + (category) => (input.categories[category]?.auditRefs?.length ?? 0) > 0, + ); + + const issues: StoredLighthouseIssue[] = []; + + for (const category of LIGHTHOUSE_CATEGORIES) { + const refs = input.categories[category]?.auditRefs ?? []; + for (const ref of refs) { + const auditKey = ref.id; + if (!auditKey) continue; + + const audit = input.audits[auditKey]; + if (!audit) continue; + + const score = scoreToPercent(audit.score); + const scoreDisplayMode = audit.scoreDisplayMode ?? null; + + if (scoreDisplayMode === "numeric") continue; + if (DIAGNOSTIC_AUDIT_KEYS.has(auditKey)) continue; + + const isPass = + score == null || + (score != null && score >= 90) || + scoreDisplayMode === "notApplicable" || + scoreDisplayMode === "informative" || + scoreDisplayMode === "manual" || + scoreDisplayMode === "error"; + + if (isPass) continue; + + const impactMs = + typeof audit.details?.overallSavingsMs === "number" + ? audit.details.overallSavingsMs + : null; + const impactBytes = + typeof audit.details?.overallSavingsBytes === "number" + ? audit.details.overallSavingsBytes + : null; + const items = Array.isArray(audit.details?.items) + ? audit.details.items.slice(0, 10).map(compactItem) + : []; + + issues.push({ + category, + auditKey, + title: audit.title ?? auditKey, + description: audit.description ?? "", + score, + scoreDisplayMode, + displayValue: audit.displayValue ?? null, + impactMs, + impactBytes, + severity: getSeverity({ score, impactMs, impactBytes }), + items, + }); + } + } + + return { + hasIssueDetails, + issues, + }; +} + +export function buildStoredLighthouseMetrics(input: { + audits: Record; +}): StoredLighthouseMetrics { + return { + firstContentfulPaint: buildStoredMetric( + input.audits["first-contentful-paint"], + ), + largestContentfulPaint: buildStoredMetric( + input.audits["largest-contentful-paint"], + ), + totalBlockingTime: buildStoredMetric(input.audits["total-blocking-time"]), + cumulativeLayoutShift: buildStoredMetric( + input.audits["cumulative-layout-shift"], + ), + speedIndex: buildStoredMetric(input.audits["speed-index"]), + timeToInteractive: buildStoredMetric(input.audits.interactive), + interactionToNextPaint: buildStoredMetric( + input.audits["interaction-to-next-paint"], + ), + serverResponseTime: buildStoredMetric(input.audits["server-response-time"]), + }; +} diff --git a/src/server/workflows/SiteAuditWorkflow.ts b/src/server/workflows/SiteAuditWorkflow.ts index 5490347..9f63eb1 100644 --- a/src/server/workflows/SiteAuditWorkflow.ts +++ b/src/server/workflows/SiteAuditWorkflow.ts @@ -9,12 +9,14 @@ import { type WorkflowEvent, type WorkflowStep, } from "cloudflare:workers"; +import type { BillingCustomerContext } from "@/server/billing/subscription"; import { AuditRepository } from "@/server/features/audit/repositories/AuditRepository"; import type { AuditConfig } from "@/server/lib/audit/types"; import { runAuditPhases } from "@/server/workflows/siteAuditWorkflowPhases"; interface AuditParams { auditId: string; + billingCustomer: BillingCustomerContext; projectId: string; startUrl: string; config: AuditConfig; @@ -22,7 +24,8 @@ interface AuditParams { export class SiteAuditWorkflow extends WorkflowEntrypoint { async run(event: WorkflowEvent, step: WorkflowStep) { - const { auditId, projectId, startUrl, config } = event.payload; + const { auditId, billingCustomer, projectId, startUrl, config } = + event.payload; const audit = await AuditRepository.getAuditForWorkflow( auditId, @@ -41,6 +44,7 @@ export class SiteAuditWorkflow extends WorkflowEntrypoint { await runAuditPhases(step, { auditId, workflowInstanceId: event.instanceId, + billingCustomer, projectId, startUrl, config, diff --git a/src/server/workflows/site-audit-workflow-helpers.ts b/src/server/workflows/site-audit-workflow-helpers.ts index 27f26bd..82f7ec5 100644 --- a/src/server/workflows/site-audit-workflow-helpers.ts +++ b/src/server/workflows/site-audit-workflow-helpers.ts @@ -1,64 +1,6 @@ import { analyzeHtml } from "@/server/lib/audit/page-analyzer"; -import { fetchPsiResult } from "@/server/lib/audit/psi"; +import type { StepPageResult } from "@/server/lib/audit/types"; import { isSameOrigin, normalizeUrl } from "@/server/lib/audit/url-utils"; -import type { PsiResult } from "@/server/lib/audit/types"; -import { putTextToR2 } from "@/server/lib/r2"; - -export interface StepPageResult { - id: string; - url: string; - statusCode: number; - redirectUrl: string | null; - title: string; - metaDescription: string; - canonicalUrl: string | null; - robotsMeta: string | null; - ogTitle: string | null; - ogDescription: string | null; - ogImage: string | null; - h1Count: number; - h2Count: number; - h3Count: number; - h4Count: number; - h5Count: number; - h6Count: number; - headingOrder: number[]; - wordCount: number; - imagesTotal: number; - imagesMissingAlt: number; - images: Array<{ src: string | null; alt: string | null }>; - internalLinks: string[]; - externalLinks: string[]; - hasStructuredData: boolean; - hreflangTags: string[]; - isIndexable: boolean; - responseTimeMs: number; -} - -type PsiUploadContext = { - projectId: string; - auditId: string; -}; - -export async function fetchPsiAndUploadToR2( - url: string, - pageId: string, - strategy: "mobile" | "desktop", - apiKey: string, - context: PsiUploadContext, -): Promise { - const result = await fetchPsiResult(url, pageId, strategy, apiKey); - - if (result.rawPayloadJson) { - const key = `site-audit/${context.projectId}/${context.auditId}/${pageId}-${strategy}.json`; - const uploaded = await putTextToR2(key, result.rawPayloadJson); - result.r2Key = uploaded.key; - result.payloadSizeBytes = uploaded.sizeBytes; - result.rawPayloadJson = null; - } - - return result; -} export async function crawlPage( url: string, diff --git a/src/server/workflows/siteAuditWorkflowCrawl.ts b/src/server/workflows/siteAuditWorkflowCrawl.ts index b9907f9..8245600 100644 --- a/src/server/workflows/siteAuditWorkflowCrawl.ts +++ b/src/server/workflows/siteAuditWorkflowCrawl.ts @@ -1,12 +1,10 @@ import type { WorkflowStep } from "cloudflare:workers"; import type { RobotsResult } from "@/server/lib/audit/discovery"; +import type { StepPageResult } from "@/server/lib/audit/types"; import { isSameOrigin, normalizeUrl } from "@/server/lib/audit/url-utils"; import { AuditRepository } from "@/server/features/audit/repositories/AuditRepository"; import { AuditProgressKV } from "@/server/lib/audit/progress-kv"; -import { - crawlPage, - type StepPageResult, -} from "@/server/workflows/site-audit-workflow-helpers"; +import { crawlPage } from "@/server/workflows/site-audit-workflow-helpers"; const CRAWL_CONCURRENCY = 25; diff --git a/src/server/workflows/siteAuditWorkflowPhases.ts b/src/server/workflows/siteAuditWorkflowPhases.ts index f1dd2e7..0b39715 100644 --- a/src/server/workflows/siteAuditWorkflowPhases.ts +++ b/src/server/workflows/siteAuditWorkflowPhases.ts @@ -1,19 +1,23 @@ import type { WorkflowStep } from "cloudflare:workers"; +import type { BillingCustomerContext } from "@/server/billing/subscription"; import { discoverUrls, fetchRobotsTxt } from "@/server/lib/audit/discovery"; -import { selectPsiSample } from "@/server/lib/audit/psi"; +import { + fetchAndStoreLighthouseResult, + selectLighthouseSample, +} from "@/server/lib/audit/lighthouse"; import { getOrigin } from "@/server/lib/audit/url-utils"; import { AuditRepository } from "@/server/features/audit/repositories/AuditRepository"; import { AuditProgressKV } from "@/server/lib/audit/progress-kv"; -import type { AuditConfig, PsiResult } from "@/server/lib/audit/types"; -import { - fetchPsiAndUploadToR2, - type StepPageResult, -} from "@/server/workflows/site-audit-workflow-helpers"; +import type { + AuditConfig, + LighthouseResult, + StepPageResult, +} from "@/server/lib/audit/types"; import { runCrawlPhase } from "@/server/workflows/siteAuditWorkflowCrawl"; -const PSI_URL_CONCURRENCY = 6; +const LIGHTHOUSE_URL_BATCH_SIZE = 10; -function countPsiBatchResults(results: PsiResult[]): { +function countLighthouseBatchResults(results: LighthouseResult[]): { completed: number; failed: number; } { @@ -32,6 +36,7 @@ function countPsiBatchResults(results: PsiResult[]): { type AuditPhasesParams = { auditId: string; workflowInstanceId: string; + billingCustomer: BillingCustomerContext; projectId: string; startUrl: string; config: AuditConfig; @@ -41,7 +46,14 @@ export async function runAuditPhases( step: WorkflowStep, params: AuditPhasesParams, ) { - const { auditId, workflowInstanceId, projectId, startUrl, config } = params; + const { + auditId, + workflowInstanceId, + billingCustomer, + projectId, + startUrl, + config, + } = params; const origin = getOrigin(startUrl); const maxPages = config.maxPages; @@ -62,15 +74,22 @@ export async function runAuditPhases( robots, sitemapUrls: discovery.sitemapUrls, }); - const psiResults = await runPsiPhase(step, { + const lighthouseResults = await runLighthousePhase(step, { auditId, workflowInstanceId, + billingCustomer, projectId, startUrl, config, allPages, }); - await finalizeAudit(step, auditId, workflowInstanceId, allPages, psiResults); + await finalizeAudit( + step, + auditId, + workflowInstanceId, + allPages, + lighthouseResults, + ); } async function runDiscoveryPhase( @@ -90,115 +109,134 @@ async function runDiscoveryPhase( }); } -type PsiPhaseParams = { +type LighthousePhaseParams = { auditId: string; workflowInstanceId: string; + billingCustomer: BillingCustomerContext; projectId: string; startUrl: string; config: AuditConfig; allPages: StepPageResult[]; }; -async function runPsiPhase( +async function runLighthousePhase( step: WorkflowStep, - params: PsiPhaseParams, -): Promise { - const { auditId, workflowInstanceId, projectId, startUrl, config, allPages } = - params; - if (config.psiStrategy === "none" || !config.psiApiKey) return []; + params: LighthousePhaseParams, +): Promise { + const { + auditId, + workflowInstanceId, + billingCustomer, + projectId, + startUrl, + config, + allPages, + } = params; + if (config.lighthouseStrategy === "none") return []; - const psiSample = await selectPsiUrls({ + const lighthouseWork = await selectLighthousePages({ step, auditId, workflowInstanceId, allPages, startUrl, - strategy: config.psiStrategy, - }); - const psiWork = psiSample.flatMap((psiUrl) => { - const page = allPages.find((candidate) => candidate.url === psiUrl); - if (!page) return []; - return [{ url: psiUrl, pageId: page.id }]; + strategy: config.lighthouseStrategy, }); - const psiResults: PsiResult[] = []; - let psiCompleted = 0; - let psiFailed = 0; - let psiBatchIndex = 0; + const lighthouseResults: LighthouseResult[] = []; + let completedChecks = 0; + let failedChecks = 0; + let lighthouseBatchIndex = 0; - for (let i = 0; i < psiWork.length; i += PSI_URL_CONCURRENCY) { - const batch = psiWork.slice(i, i + PSI_URL_CONCURRENCY); - psiBatchIndex += 1; - const psiBatchResults = await runPsiBatch({ + for (let i = 0; i < lighthouseWork.length; i += LIGHTHOUSE_URL_BATCH_SIZE) { + const batch = lighthouseWork.slice(i, i + LIGHTHOUSE_URL_BATCH_SIZE); + lighthouseBatchIndex += 1; + const lighthouseBatchResults = await runLighthouseBatch({ step, - psiBatchIndex, + lighthouseBatchIndex, batch, - psiApiKey: config.psiApiKey, + billingCustomer, projectId, auditId, }); - psiResults.push(...psiBatchResults); - const counts = countPsiBatchResults(psiBatchResults); - psiFailed += counts.failed; - psiCompleted += counts.completed; - await step.do(`psi-progress-batch-${psiBatchIndex}`, async () => { - await AuditRepository.updateAuditProgress(auditId, workflowInstanceId, { - psiCompleted, - psiFailed, - }); - }); + lighthouseResults.push(...lighthouseBatchResults); + const counts = countLighthouseBatchResults(lighthouseBatchResults); + failedChecks += counts.failed; + completedChecks += counts.completed; + await step.do( + `lighthouse-progress-batch-${lighthouseBatchIndex}`, + async () => { + await AuditRepository.updateAuditProgress(auditId, workflowInstanceId, { + lighthouseCompleted: completedChecks, + lighthouseFailed: failedChecks, + }); + }, + ); } - return psiResults; + return lighthouseResults; } -async function selectPsiUrls(params: { +async function selectLighthousePages(params: { step: WorkflowStep; auditId: string; workflowInstanceId: string; allPages: StepPageResult[]; startUrl: string; - strategy: AuditConfig["psiStrategy"]; + strategy: AuditConfig["lighthouseStrategy"]; }) { const { step, auditId, workflowInstanceId, allPages, startUrl, strategy } = params; - return step.do("select-psi-sample", async () => { - const pagesForSample = allPages.map((page) => ({ - id: page.id, - url: page.url, - statusCode: page.statusCode, - })); - const sample = selectPsiSample(pagesForSample, startUrl, strategy); + return step.do("select-lighthouse-sample", async () => { + const sample = selectLighthouseSample(allPages, startUrl, strategy); + const selectedUrls = new Set(sample); await AuditRepository.updateAuditProgress(auditId, workflowInstanceId, { - currentPhase: "psi", - psiTotal: sample.length * 2, - psiCompleted: 0, - psiFailed: 0, + currentPhase: "lighthouse", + lighthouseTotal: sample.length * 2, + lighthouseCompleted: 0, + lighthouseFailed: 0, }); - return sample; + return allPages.flatMap((page) => + selectedUrls.has(page.url) ? [{ url: page.url, pageId: page.id }] : [], + ); }); } -async function runPsiBatch(params: { +async function runLighthouseBatch(params: { step: WorkflowStep; - psiBatchIndex: number; + lighthouseBatchIndex: number; batch: Array<{ url: string; pageId: string }>; - psiApiKey: string; + billingCustomer: BillingCustomerContext; projectId: string; auditId: string; }) { - const { step, psiBatchIndex, batch, psiApiKey, projectId, auditId } = params; - return step.do(`psi-batch-${psiBatchIndex}`, async () => { + const { + step, + lighthouseBatchIndex, + batch, + billingCustomer, + projectId, + auditId, + } = params; + return step.do(`lighthouse-batch-${lighthouseBatchIndex}`, async () => { const perUrlResults = await Promise.all( batch.map(async ({ url, pageId }) => { const [mobileResult, desktopResult] = await Promise.all([ - fetchPsiAndUploadToR2(url, pageId, "mobile", psiApiKey, { + fetchAndStoreLighthouseResult({ + url, + pageId, + strategy: "mobile", + billingCustomer, projectId, auditId, }), - fetchPsiAndUploadToR2(url, pageId, "desktop", psiApiKey, { + fetchAndStoreLighthouseResult({ + url, + pageId, + strategy: "desktop", + billingCustomer, projectId, auditId, }), @@ -216,13 +254,17 @@ async function finalizeAudit( auditId: string, workflowInstanceId: string, allPages: StepPageResult[], - psiResults: PsiResult[], + lighthouseResults: LighthouseResult[], ) { await step.do("finalize", async () => { await AuditRepository.updateAuditProgress(auditId, workflowInstanceId, { currentPhase: "finalizing", }); - await AuditRepository.batchWriteResults(auditId, allPages, psiResults); + await AuditRepository.batchWriteResults( + auditId, + allPages, + lighthouseResults, + ); await AuditRepository.completeAudit(auditId, workflowInstanceId, { pagesCrawled: allPages.length, pagesTotal: allPages.length, diff --git a/src/serverFunctions/audit.ts b/src/serverFunctions/audit.ts index 278cc69..92627a9 100644 --- a/src/serverFunctions/audit.ts +++ b/src/serverFunctions/audit.ts @@ -1,14 +1,14 @@ import { createServerFn } from "@tanstack/react-start"; +import { AuditService } from "@/server/features/audit/services/AuditService"; import { requireProjectContext } from "@/serverFunctions/middleware"; import { - startAuditSchema, - getAuditStatusSchema, - getAuditResultsSchema, - getAuditHistorySchema, deleteAuditSchema, + getAuditHistorySchema, + getAuditResultsSchema, + getAuditStatusSchema, getCrawlProgressSchema, + startAuditSchema, } from "@/types/schemas/audit"; -import { AuditService } from "@/server/features/audit/services/AuditService"; export const startAudit = createServerFn({ method: "POST" }) .middleware(requireProjectContext) @@ -16,11 +16,14 @@ export const startAudit = createServerFn({ method: "POST" }) .handler(async ({ data, context }) => { return AuditService.startAudit({ actorUserId: context.userId, + billingCustomer: { + organizationId: context.organizationId, + userEmail: context.userEmail, + }, projectId: context.project.id, startUrl: data.startUrl, maxPages: data.maxPages, - psiStrategy: data.psiStrategy, - psiApiKey: data.psiApiKey, + lighthouseStrategy: data.lighthouseStrategy, }); }); @@ -38,9 +41,7 @@ export const getAuditResults = createServerFn({ method: "POST" }) return AuditService.getResults(data.auditId, context.project.id); }); -export const getAuditHistory = createServerFn({ - method: "POST", -}) +export const getAuditHistory = createServerFn({ method: "POST" }) .middleware(requireProjectContext) .inputValidator((data: unknown) => getAuditHistorySchema.parse(data)) .handler(async ({ context }) => { diff --git a/src/serverFunctions/lighthouse.ts b/src/serverFunctions/lighthouse.ts new file mode 100644 index 0000000..107c99b --- /dev/null +++ b/src/serverFunctions/lighthouse.ts @@ -0,0 +1,88 @@ +import { createServerFn } from "@tanstack/react-start"; +import { AuditRepository } from "@/server/features/audit/repositories/AuditRepository"; +import { + buildLighthouseExportFile, + readStoredLighthousePayload, +} from "@/server/lib/lighthousePayload"; +import { AppError } from "@/server/lib/errors"; +import { getJsonFromR2 } from "@/server/lib/r2"; +import { requireProjectContext } from "@/serverFunctions/middleware"; +import { + lighthouseAuditExportSchema, + lighthouseAuditIssueSchema, +} from "@/types/schemas/lighthouse"; + +async function getAuditLighthouseData(input: { + projectId: string; + resultId: string; +}) { + const site = await AuditRepository.getLighthouseResultById({ + lighthouseResultId: input.resultId, + projectId: input.projectId, + }); + + if (!site) { + throw new AppError("NOT_FOUND"); + } + + const r2Key = site.lighthouse.r2Key; + if (!r2Key) { + throw new AppError("NOT_FOUND"); + } + + const payloadJson = await getJsonFromR2(r2Key); + const payload = readStoredLighthousePayload(payloadJson); + + return { + id: site.lighthouse.id, + strategy: site.lighthouse.strategy, + finalUrl: site.page?.url ?? "", + createdAt: site.audit.startedAt, + payloadJson, + payload, + }; +} + +export const getAuditLighthouseIssues = createServerFn({ method: "POST" }) + .middleware(requireProjectContext) + .inputValidator((data: unknown) => lighthouseAuditIssueSchema.parse(data)) + .handler(async ({ data, context }) => { + const lighthouse = await getAuditLighthouseData({ + projectId: context.project.id, + resultId: data.resultId, + }); + + return { + id: lighthouse.id, + finalUrl: + lighthouse.payload.storedPayload?.metadata.finalUrl ?? + lighthouse.finalUrl, + strategy: lighthouse.strategy, + createdAt: lighthouse.createdAt, + hasIssueDetails: lighthouse.payload.report.hasIssueDetails, + scores: lighthouse.payload.storedPayload?.scores ?? null, + metrics: lighthouse.payload.storedPayload?.metrics ?? null, + issues: lighthouse.payload.report.issues, + }; + }); + +export const exportAuditLighthouseIssues = createServerFn({ method: "POST" }) + .middleware(requireProjectContext) + .inputValidator((data: unknown) => lighthouseAuditExportSchema.parse(data)) + .handler(async ({ data, context }) => { + const lighthouse = await getAuditLighthouseData({ + projectId: context.project.id, + resultId: data.resultId, + }); + + return buildLighthouseExportFile({ + idField: "resultId", + idValue: lighthouse.id, + finalUrl: lighthouse.finalUrl, + strategy: lighthouse.strategy, + createdAt: lighthouse.createdAt, + payloadJson: lighthouse.payloadJson, + mode: data.mode, + category: data.mode === "category" ? data.category : undefined, + }); + }); diff --git a/src/serverFunctions/projects.ts b/src/serverFunctions/projects.ts index 6aec5a6..ecd3045 100644 --- a/src/serverFunctions/projects.ts +++ b/src/serverFunctions/projects.ts @@ -1,9 +1,6 @@ import { createServerFn } from "@tanstack/react-start"; import { ProjectService } from "@/server/features/projects/services/ProjectService"; -import { - requireAuthenticatedContext, - requireProjectContext, -} from "@/serverFunctions/middleware"; +import { requireAuthenticatedContext } from "@/serverFunctions/middleware"; import { z } from "zod"; export const getOrCreateDefaultProject = createServerFn({ method: "POST" }) @@ -13,13 +10,13 @@ export const getOrCreateDefaultProject = createServerFn({ method: "POST" }) ); export const getProjectAccess = createServerFn({ method: "POST" }) - .middleware(requireProjectContext) + .middleware(requireAuthenticatedContext) .inputValidator((data: unknown) => z.object({ projectId: z.string().min(1) }).parse(data), ) - .handler(async ({ context }) => { + .handler(async ({ data, context }) => { return ProjectService.getProjectForOrganization( context.organizationId, - context.project.id, + data.projectId, ); }); diff --git a/src/serverFunctions/psi.ts b/src/serverFunctions/psi.ts deleted file mode 100644 index 1d0edd9..0000000 --- a/src/serverFunctions/psi.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { createServerFn } from "@tanstack/react-start"; -import { PsiAuditService } from "@/server/features/psi/services/PsiAuditService"; -import { requireProjectContext } from "@/serverFunctions/middleware"; -import { - psiAuditIssueSchema, - psiAuditExportSchema, - psiProjectKeySchema, - psiProjectSchema, -} from "@/types/schemas/psi"; - -export const getProjectPsiApiKey = createServerFn({ - method: "POST", -}) - .middleware(requireProjectContext) - .inputValidator((data: unknown) => psiProjectSchema.parse(data)) - .handler(async ({ context }) => { - return PsiAuditService.getProjectPsiApiKey({ - projectId: context.project.id, - }); - }); - -export const saveProjectPsiApiKey = createServerFn({ - method: "POST", -}) - .middleware(requireProjectContext) - .inputValidator((data: unknown) => psiProjectKeySchema.parse(data)) - .handler(async ({ data, context }) => { - return PsiAuditService.saveProjectPsiApiKey({ - projectId: context.project.id, - apiKey: data.apiKey, - }); - }); - -export const clearProjectPsiApiKey = createServerFn({ - method: "POST", -}) - .middleware(requireProjectContext) - .inputValidator((data: unknown) => psiProjectSchema.parse(data)) - .handler(async ({ context }) => { - return PsiAuditService.clearProjectPsiApiKey({ - projectId: context.project.id, - }); - }); - -export const getAuditPsiIssues = createServerFn({ method: "POST" }) - .middleware(requireProjectContext) - .inputValidator((data: unknown) => psiAuditIssueSchema.parse(data)) - .handler(async ({ data, context }) => { - return PsiAuditService.getAuditPsiIssues({ - projectId: context.project.id, - resultId: data.resultId, - category: data.category, - }); - }); - -export const exportAuditPsi = createServerFn({ method: "POST" }) - .middleware(requireProjectContext) - .inputValidator((data: unknown) => psiAuditExportSchema.parse(data)) - .handler(async ({ data, context }) => { - return PsiAuditService.exportAuditPsi({ - projectId: context.project.id, - resultId: data.resultId, - mode: data.mode, - category: data.category, - }); - }); diff --git a/src/shared/lighthouse.ts b/src/shared/lighthouse.ts new file mode 100644 index 0000000..79f8bb1 --- /dev/null +++ b/src/shared/lighthouse.ts @@ -0,0 +1,14 @@ +export const LIGHTHOUSE_CATEGORIES = [ + "performance", + "accessibility", + "best-practices", + "seo", +] as const; + +export const LIGHTHOUSE_CATEGORY_TABS = [ + "all", + ...LIGHTHOUSE_CATEGORIES, +] as const; + +export type LighthouseCategory = (typeof LIGHTHOUSE_CATEGORIES)[number]; +export type LighthouseCategoryTab = (typeof LIGHTHOUSE_CATEGORY_TABS)[number]; diff --git a/src/types/schemas/audit.ts b/src/types/schemas/audit.ts index 996350b..450e3c6 100644 --- a/src/types/schemas/audit.ts +++ b/src/types/schemas/audit.ts @@ -6,11 +6,10 @@ export const startAuditSchema = z.object({ projectId: z.string().min(1), startUrl: z.string().min(1, "URL is required").max(2048), maxPages: z.number().int().min(10).max(10_000).optional().default(50), - psiStrategy: z + lighthouseStrategy: z .enum(["auto", "all", "manual", "none"]) .optional() .default("auto"), - psiApiKey: z.string().optional(), }); export const getAuditStatusSchema = z.object({ diff --git a/src/types/schemas/lighthouse.ts b/src/types/schemas/lighthouse.ts new file mode 100644 index 0000000..dc19f11 --- /dev/null +++ b/src/types/schemas/lighthouse.ts @@ -0,0 +1,22 @@ +import { z } from "zod"; +import { + LIGHTHOUSE_CATEGORIES, + LIGHTHOUSE_CATEGORY_TABS, +} from "@/shared/lighthouse"; + +export const lighthouseAuditIssueSchema = z.object({ + projectId: z.string().min(1, "Project id is required"), + resultId: z.string().min(1, "Result id is required"), +}); + +export const lighthouseAuditExportSchema = z.object({ + projectId: z.string().min(1, "Project id is required"), + resultId: z.string().min(1, "Result id is required"), + mode: z.enum(["full", "issues", "category"]), + category: z.enum(LIGHTHOUSE_CATEGORIES).optional(), +}); + +export const lighthouseIssuesSearchSchema = z.object({ + auditId: z.string().optional().catch(undefined), + category: z.enum(LIGHTHOUSE_CATEGORY_TABS).catch("all").default("all"), +}); diff --git a/src/types/schemas/psi.ts b/src/types/schemas/psi.ts deleted file mode 100644 index b0d9209..0000000 --- a/src/types/schemas/psi.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { z } from "zod"; - -const psiCategories = [ - "performance", - "accessibility", - "best-practices", - "seo", -] as const; - -export const psiProjectKeySchema = z.object({ - projectId: z.string().min(1, "Project is required"), - apiKey: z.string().min(1, "API key is required").max(512), -}); - -export const psiProjectSchema = z.object({ - projectId: z.string().min(1, "Project is required"), -}); - -export const psiAuditIssueSchema = z.object({ - projectId: z.string().min(1, "Project is required"), - resultId: z.string().min(1, "Result id is required"), - category: z.enum(psiCategories).optional(), -}); - -export const psiAuditExportSchema = z.object({ - projectId: z.string().min(1, "Project is required"), - resultId: z.string().min(1, "Result id is required"), - mode: z.enum(["full", "issues", "category"]), - category: z.enum(psiCategories).optional(), -}); - -export const psiIssuesSearchSchema = z.object({ - category: z - .enum(["all", ...psiCategories]) - .catch("all") - .default("all"), -});