From 570fe2aa62d7c616497a2d58d40f615d5a44b413 Mon Sep 17 00:00:00 2001 From: Ben Senescu <44480372+bensenescu@users.noreply.github.com> Date: Tue, 12 May 2026 00:02:43 -0400 Subject: [PATCH] Add keyword tags, server-side saved filtering, and filtered exports (#179) --- drizzle/0013_fat_network.sql | 22 + drizzle/0014_tag_color.sql | 1 + drizzle/meta/0013_snapshot.json | 2793 ++++++++++++++++ drizzle/meta/0014_snapshot.json | 2800 +++++++++++++++++ drizzle/meta/_journal.json | 14 + .../domain/components/DifficultyBadge.tsx | 11 +- .../page/KeywordResearchDesktopTable.tsx | 17 +- .../features/saved-keywords/ManageTagRow.tsx | 103 + .../SavedKeywordsBulkActionBar.tsx | 152 + .../SavedKeywordsBulkTagsModal.tsx | 322 ++ .../SavedKeywordsFilterPanel.tsx | 282 ++ .../saved-keywords/SavedKeywordsFilters.tsx | 76 + .../saved-keywords/SavedKeywordsHeader.tsx | 63 + .../saved-keywords/SavedKeywordsModals.tsx | 54 + .../SavedKeywordsPagination.tsx | 86 + .../saved-keywords/SavedKeywordsStatus.tsx | 19 + .../saved-keywords/SavedKeywordsTable.tsx | 203 ++ .../saved-keywords/SavedKeywordsTagFilter.tsx | 318 ++ .../features/saved-keywords/TagChip.tsx | 66 + .../savedKeywordsFilterTypes.ts | 89 + .../saved-keywords/savedKeywordsUtils.ts | 50 + .../saved-keywords/useSavedKeywordsExport.ts | 142 + .../saved-keywords/useSavedKeywordsFilters.ts | 36 + .../features/saved-keywords/useTagManage.ts | 72 + src/client/styles/app.css | 154 +- src/db/app.schema.ts | 51 + src/routes/_project/p/$projectId/saved.tsx | 602 ++-- .../repositories/KeywordResearchRepository.ts | 279 +- .../SavedKeywordTagsRepository.ts | 413 +++ .../services/KeywordResearchService.ts | 8 + .../keywords/services/research/index.ts | 4 + .../services/research/saved-keywords.test.ts | 248 ++ .../services/research/saved-keywords.ts | 230 +- src/server/mcp/tools/list-saved-keywords.ts | 47 +- src/server/mcp/tools/save-keywords.ts | 43 +- .../mcp/tools/saved-keywords-tools.test.ts | 191 ++ src/serverFunctions/keywords.ts | 44 + src/shared/saved-keyword-tags.test.ts | 32 + src/shared/saved-keyword-tags.ts | 35 + src/shared/tag-colors.ts | 58 + src/types/keywords.ts | 13 + src/types/schemas/keywords.ts | 175 +- 42 files changed, 9947 insertions(+), 471 deletions(-) create mode 100644 drizzle/0013_fat_network.sql create mode 100644 drizzle/0014_tag_color.sql create mode 100644 drizzle/meta/0013_snapshot.json create mode 100644 drizzle/meta/0014_snapshot.json create mode 100644 src/client/features/saved-keywords/ManageTagRow.tsx create mode 100644 src/client/features/saved-keywords/SavedKeywordsBulkActionBar.tsx create mode 100644 src/client/features/saved-keywords/SavedKeywordsBulkTagsModal.tsx create mode 100644 src/client/features/saved-keywords/SavedKeywordsFilterPanel.tsx create mode 100644 src/client/features/saved-keywords/SavedKeywordsFilters.tsx create mode 100644 src/client/features/saved-keywords/SavedKeywordsHeader.tsx create mode 100644 src/client/features/saved-keywords/SavedKeywordsModals.tsx create mode 100644 src/client/features/saved-keywords/SavedKeywordsPagination.tsx create mode 100644 src/client/features/saved-keywords/SavedKeywordsStatus.tsx create mode 100644 src/client/features/saved-keywords/SavedKeywordsTable.tsx create mode 100644 src/client/features/saved-keywords/SavedKeywordsTagFilter.tsx create mode 100644 src/client/features/saved-keywords/TagChip.tsx create mode 100644 src/client/features/saved-keywords/savedKeywordsFilterTypes.ts create mode 100644 src/client/features/saved-keywords/savedKeywordsUtils.ts create mode 100644 src/client/features/saved-keywords/useSavedKeywordsExport.ts create mode 100644 src/client/features/saved-keywords/useSavedKeywordsFilters.ts create mode 100644 src/client/features/saved-keywords/useTagManage.ts create mode 100644 src/server/features/keywords/repositories/SavedKeywordTagsRepository.ts create mode 100644 src/server/features/keywords/services/research/saved-keywords.test.ts create mode 100644 src/server/mcp/tools/saved-keywords-tools.test.ts create mode 100644 src/shared/saved-keyword-tags.test.ts create mode 100644 src/shared/saved-keyword-tags.ts create mode 100644 src/shared/tag-colors.ts diff --git a/drizzle/0013_fat_network.sql b/drizzle/0013_fat_network.sql new file mode 100644 index 0000000..c1be65e --- /dev/null +++ b/drizzle/0013_fat_network.sql @@ -0,0 +1,22 @@ +CREATE TABLE `saved_keyword_tags` ( + `id` text PRIMARY KEY NOT NULL, + `project_id` text NOT NULL, + `name` text NOT NULL, + `normalized_name` text NOT NULL, + `created_at` text DEFAULT (current_timestamp) NOT NULL, + FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `saved_keyword_tags_project_normalized_name_idx` ON `saved_keyword_tags` (`project_id`,`normalized_name`);--> statement-breakpoint +CREATE INDEX `saved_keyword_tags_project_name_idx` ON `saved_keyword_tags` (`project_id`,`name`);--> statement-breakpoint +CREATE TABLE `saved_keyword_tag_assignments` ( + `saved_keyword_id` text NOT NULL, + `tag_id` text NOT NULL, + `created_at` text DEFAULT (current_timestamp) NOT NULL, + FOREIGN KEY (`saved_keyword_id`) REFERENCES `saved_keywords`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`tag_id`) REFERENCES `saved_keyword_tags`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `saved_keyword_tag_assignments_unique_idx` ON `saved_keyword_tag_assignments` (`saved_keyword_id`,`tag_id`);--> statement-breakpoint +CREATE INDEX `saved_keyword_tag_assignments_keyword_idx` ON `saved_keyword_tag_assignments` (`saved_keyword_id`);--> statement-breakpoint +CREATE INDEX `saved_keyword_tag_assignments_tag_idx` ON `saved_keyword_tag_assignments` (`tag_id`); diff --git a/drizzle/0014_tag_color.sql b/drizzle/0014_tag_color.sql new file mode 100644 index 0000000..c3a9ab3 --- /dev/null +++ b/drizzle/0014_tag_color.sql @@ -0,0 +1 @@ +ALTER TABLE `saved_keyword_tags` ADD `color` text; \ No newline at end of file diff --git a/drizzle/meta/0013_snapshot.json b/drizzle/meta/0013_snapshot.json new file mode 100644 index 0000000..69d2520 --- /dev/null +++ b/drizzle/meta/0013_snapshot.json @@ -0,0 +1,2793 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "5c764345-671f-4c90-b9c3-d7af7decd87b", + "prevId": "4aecfb5a-9351-40f4-b306-4e65e5d29fe7", + "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": {} + }, + "rank_check_runs": { + "name": "rank_check_runs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "keywords_total": { + "name": "keywords_total", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "keywords_checked": { + "name": "keywords_checked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "is_subset_run": { + "name": "is_subset_run", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "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": { + "rank_check_runs_config_idx": { + "name": "rank_check_runs_config_idx", + "columns": [ + "config_id", + "started_at" + ], + "isUnique": false + }, + "rank_check_runs_project_idx": { + "name": "rank_check_runs_project_idx", + "columns": [ + "project_id", + "started_at" + ], + "isUnique": false + }, + "rank_check_runs_one_active_per_config_idx": { + "name": "rank_check_runs_one_active_per_config_idx", + "columns": [ + "config_id" + ], + "isUnique": true, + "where": "\"rank_check_runs\".\"status\" IN ('pending', 'running')" + } + }, + "foreignKeys": { + "rank_check_runs_config_id_rank_tracking_configs_id_fk": { + "name": "rank_check_runs_config_id_rank_tracking_configs_id_fk", + "tableFrom": "rank_check_runs", + "tableTo": "rank_tracking_configs", + "columnsFrom": [ + "config_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "rank_check_runs_project_id_projects_id_fk": { + "name": "rank_check_runs_project_id_projects_id_fk", + "tableFrom": "rank_check_runs", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rank_snapshots": { + "name": "rank_snapshots", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tracking_keyword_id": { + "name": "tracking_keyword_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyword": { + "name": "keyword", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device": { + "name": "device", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "serp_features": { + "name": "serp_features", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checked_at": { + "name": "checked_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "rank_snapshots_run_idx": { + "name": "rank_snapshots_run_idx", + "columns": [ + "run_id" + ], + "isUnique": false + }, + "rank_snapshots_keyword_device_idx": { + "name": "rank_snapshots_keyword_device_idx", + "columns": [ + "tracking_keyword_id", + "device", + "checked_at" + ], + "isUnique": false + }, + "rank_snapshots_run_keyword_device_idx": { + "name": "rank_snapshots_run_keyword_device_idx", + "columns": [ + "run_id", + "tracking_keyword_id", + "device" + ], + "isUnique": true + } + }, + "foreignKeys": { + "rank_snapshots_run_id_rank_check_runs_id_fk": { + "name": "rank_snapshots_run_id_rank_check_runs_id_fk", + "tableFrom": "rank_snapshots", + "tableTo": "rank_check_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rank_tracking_configs": { + "name": "rank_tracking_configs", + "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 + }, + "domain": { + "name": "domain", + "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'" + }, + "devices": { + "name": "devices", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'both'" + }, + "serp_depth": { + "name": "serp_depth", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schedule_interval": { + "name": "schedule_interval", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'weekly'" + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_check_at": { + "name": "next_check_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_skip_reason": { + "name": "last_skip_reason", + "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": { + "rank_tracking_configs_project_domain_location_idx": { + "name": "rank_tracking_configs_project_domain_location_idx", + "columns": [ + "project_id", + "domain", + "location_code" + ], + "isUnique": true + } + }, + "foreignKeys": { + "rank_tracking_configs_project_id_projects_id_fk": { + "name": "rank_tracking_configs_project_id_projects_id_fk", + "tableFrom": "rank_tracking_configs", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rank_tracking_keywords": { + "name": "rank_tracking_keywords", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyword": { + "name": "keyword", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "search_volume": { + "name": "search_volume", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "keyword_difficulty": { + "name": "keyword_difficulty", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cpc": { + "name": "cpc", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metrics_fetched_at": { + "name": "metrics_fetched_at", + "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": { + "rank_tracking_keywords_config_keyword_idx": { + "name": "rank_tracking_keywords_config_keyword_idx", + "columns": [ + "config_id", + "keyword" + ], + "isUnique": true + } + }, + "foreignKeys": { + "rank_tracking_keywords_config_id_rank_tracking_configs_id_fk": { + "name": "rank_tracking_keywords_config_id_rank_tracking_configs_id_fk", + "tableFrom": "rank_tracking_keywords", + "tableTo": "rank_tracking_configs", + "columnsFrom": [ + "config_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "saved_keyword_tag_assignments": { + "name": "saved_keyword_tag_assignments", + "columns": { + "saved_keyword_id": { + "name": "saved_keyword_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag_id": { + "name": "tag_id", + "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": { + "saved_keyword_tag_assignments_unique_idx": { + "name": "saved_keyword_tag_assignments_unique_idx", + "columns": [ + "saved_keyword_id", + "tag_id" + ], + "isUnique": true + }, + "saved_keyword_tag_assignments_keyword_idx": { + "name": "saved_keyword_tag_assignments_keyword_idx", + "columns": [ + "saved_keyword_id" + ], + "isUnique": false + }, + "saved_keyword_tag_assignments_tag_idx": { + "name": "saved_keyword_tag_assignments_tag_idx", + "columns": [ + "tag_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "saved_keyword_tag_assignments_saved_keyword_id_saved_keywords_id_fk": { + "name": "saved_keyword_tag_assignments_saved_keyword_id_saved_keywords_id_fk", + "tableFrom": "saved_keyword_tag_assignments", + "tableTo": "saved_keywords", + "columnsFrom": [ + "saved_keyword_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "saved_keyword_tag_assignments_tag_id_saved_keyword_tags_id_fk": { + "name": "saved_keyword_tag_assignments_tag_id_saved_keyword_tags_id_fk", + "tableFrom": "saved_keyword_tag_assignments", + "tableTo": "saved_keyword_tags", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "saved_keyword_tags": { + "name": "saved_keyword_tags", + "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 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "normalized_name": { + "name": "normalized_name", + "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": { + "saved_keyword_tags_project_normalized_name_idx": { + "name": "saved_keyword_tags_project_normalized_name_idx", + "columns": [ + "project_id", + "normalized_name" + ], + "isUnique": true + }, + "saved_keyword_tags_project_name_idx": { + "name": "saved_keyword_tags_project_name_idx", + "columns": [ + "project_id", + "name" + ], + "isUnique": false + } + }, + "foreignKeys": { + "saved_keyword_tags_project_id_projects_id_fk": { + "name": "saved_keyword_tags_project_id_projects_id_fk", + "tableFrom": "saved_keyword_tags", + "tableTo": "projects", + "columnsFrom": [ + "project_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": {} + }, + "jwks": { + "name": "jwks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "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": {} + }, + "oauth_access_token": { + "name": "oauth_access_token", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_id": { + "name": "refresh_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "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 + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "oauth_access_token_token_unique": { + "name": "oauth_access_token_token_unique", + "columns": [ + "token" + ], + "isUnique": true + } + }, + "foreignKeys": { + "oauth_access_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_access_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_session_id_session_id_fk": { + "name": "oauth_access_token_session_id_session_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_access_token_user_id_user_id_fk": { + "name": "oauth_access_token_user_id_user_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_refresh_id_oauth_refresh_token_id_fk": { + "name": "oauth_access_token_refresh_id_oauth_refresh_token_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_refresh_token", + "columnsFrom": [ + "refresh_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_client": { + "name": "oauth_client", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "disabled": { + "name": "disabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "skip_consent": { + "name": "skip_consent", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enable_end_session": { + "name": "enable_end_session", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "contacts": { + "name": "contacts", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tos": { + "name": "tos", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "software_id": { + "name": "software_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "software_version": { + "name": "software_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "software_statement": { + "name": "software_statement", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "post_logout_redirect_uris": { + "name": "post_logout_redirect_uris", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "grant_types": { + "name": "grant_types", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "response_types": { + "name": "response_types", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "public": { + "name": "public", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "require_pkce": { + "name": "require_pkce", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "oauth_client_client_id_unique": { + "name": "oauth_client_client_id_unique", + "columns": [ + "client_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "oauth_client_user_id_user_id_fk": { + "name": "oauth_client_user_id_user_id_fk", + "tableFrom": "oauth_client", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_consent": { + "name": "oauth_consent", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_consent_client_id_oauth_client_client_id_fk": { + "name": "oauth_consent_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consent_user_id_user_id_fk": { + "name": "oauth_consent_user_id_user_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_refresh_token": { + "name": "oauth_refresh_token", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "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 + }, + "revoked": { + "name": "revoked", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auth_time": { + "name": "auth_time", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_refresh_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_refresh_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_token_session_id_session_id_fk": { + "name": "oauth_refresh_token_session_id_session_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_refresh_token_user_id_user_id_fk": { + "name": "oauth_refresh_token_user_id_user_id_fk", + "tableFrom": "oauth_refresh_token", + "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))" + }, + "analytics_opted_out": { + "name": "analytics_opted_out", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "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/0014_snapshot.json b/drizzle/meta/0014_snapshot.json new file mode 100644 index 0000000..f438ff2 --- /dev/null +++ b/drizzle/meta/0014_snapshot.json @@ -0,0 +1,2800 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "b8c6e1a0-2c5d-4f3a-9a01-9f6e87a3b401", + "prevId": "5c764345-671f-4c90-b9c3-d7af7decd87b", + "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": {} + }, + "rank_check_runs": { + "name": "rank_check_runs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "keywords_total": { + "name": "keywords_total", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "keywords_checked": { + "name": "keywords_checked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "is_subset_run": { + "name": "is_subset_run", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "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": { + "rank_check_runs_config_idx": { + "name": "rank_check_runs_config_idx", + "columns": [ + "config_id", + "started_at" + ], + "isUnique": false + }, + "rank_check_runs_project_idx": { + "name": "rank_check_runs_project_idx", + "columns": [ + "project_id", + "started_at" + ], + "isUnique": false + }, + "rank_check_runs_one_active_per_config_idx": { + "name": "rank_check_runs_one_active_per_config_idx", + "columns": [ + "config_id" + ], + "isUnique": true, + "where": "\"rank_check_runs\".\"status\" IN ('pending', 'running')" + } + }, + "foreignKeys": { + "rank_check_runs_config_id_rank_tracking_configs_id_fk": { + "name": "rank_check_runs_config_id_rank_tracking_configs_id_fk", + "tableFrom": "rank_check_runs", + "tableTo": "rank_tracking_configs", + "columnsFrom": [ + "config_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "rank_check_runs_project_id_projects_id_fk": { + "name": "rank_check_runs_project_id_projects_id_fk", + "tableFrom": "rank_check_runs", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rank_snapshots": { + "name": "rank_snapshots", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tracking_keyword_id": { + "name": "tracking_keyword_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyword": { + "name": "keyword", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device": { + "name": "device", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "serp_features": { + "name": "serp_features", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checked_at": { + "name": "checked_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "rank_snapshots_run_idx": { + "name": "rank_snapshots_run_idx", + "columns": [ + "run_id" + ], + "isUnique": false + }, + "rank_snapshots_keyword_device_idx": { + "name": "rank_snapshots_keyword_device_idx", + "columns": [ + "tracking_keyword_id", + "device", + "checked_at" + ], + "isUnique": false + }, + "rank_snapshots_run_keyword_device_idx": { + "name": "rank_snapshots_run_keyword_device_idx", + "columns": [ + "run_id", + "tracking_keyword_id", + "device" + ], + "isUnique": true + } + }, + "foreignKeys": { + "rank_snapshots_run_id_rank_check_runs_id_fk": { + "name": "rank_snapshots_run_id_rank_check_runs_id_fk", + "tableFrom": "rank_snapshots", + "tableTo": "rank_check_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rank_tracking_configs": { + "name": "rank_tracking_configs", + "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 + }, + "domain": { + "name": "domain", + "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'" + }, + "devices": { + "name": "devices", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'both'" + }, + "serp_depth": { + "name": "serp_depth", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schedule_interval": { + "name": "schedule_interval", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'weekly'" + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_check_at": { + "name": "next_check_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_skip_reason": { + "name": "last_skip_reason", + "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": { + "rank_tracking_configs_project_domain_location_idx": { + "name": "rank_tracking_configs_project_domain_location_idx", + "columns": [ + "project_id", + "domain", + "location_code" + ], + "isUnique": true + } + }, + "foreignKeys": { + "rank_tracking_configs_project_id_projects_id_fk": { + "name": "rank_tracking_configs_project_id_projects_id_fk", + "tableFrom": "rank_tracking_configs", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rank_tracking_keywords": { + "name": "rank_tracking_keywords", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyword": { + "name": "keyword", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "search_volume": { + "name": "search_volume", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "keyword_difficulty": { + "name": "keyword_difficulty", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cpc": { + "name": "cpc", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metrics_fetched_at": { + "name": "metrics_fetched_at", + "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": { + "rank_tracking_keywords_config_keyword_idx": { + "name": "rank_tracking_keywords_config_keyword_idx", + "columns": [ + "config_id", + "keyword" + ], + "isUnique": true + } + }, + "foreignKeys": { + "rank_tracking_keywords_config_id_rank_tracking_configs_id_fk": { + "name": "rank_tracking_keywords_config_id_rank_tracking_configs_id_fk", + "tableFrom": "rank_tracking_keywords", + "tableTo": "rank_tracking_configs", + "columnsFrom": [ + "config_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "saved_keyword_tag_assignments": { + "name": "saved_keyword_tag_assignments", + "columns": { + "saved_keyword_id": { + "name": "saved_keyword_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag_id": { + "name": "tag_id", + "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": { + "saved_keyword_tag_assignments_unique_idx": { + "name": "saved_keyword_tag_assignments_unique_idx", + "columns": [ + "saved_keyword_id", + "tag_id" + ], + "isUnique": true + }, + "saved_keyword_tag_assignments_keyword_idx": { + "name": "saved_keyword_tag_assignments_keyword_idx", + "columns": [ + "saved_keyword_id" + ], + "isUnique": false + }, + "saved_keyword_tag_assignments_tag_idx": { + "name": "saved_keyword_tag_assignments_tag_idx", + "columns": [ + "tag_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "saved_keyword_tag_assignments_saved_keyword_id_saved_keywords_id_fk": { + "name": "saved_keyword_tag_assignments_saved_keyword_id_saved_keywords_id_fk", + "tableFrom": "saved_keyword_tag_assignments", + "tableTo": "saved_keywords", + "columnsFrom": [ + "saved_keyword_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "saved_keyword_tag_assignments_tag_id_saved_keyword_tags_id_fk": { + "name": "saved_keyword_tag_assignments_tag_id_saved_keyword_tags_id_fk", + "tableFrom": "saved_keyword_tag_assignments", + "tableTo": "saved_keyword_tags", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "saved_keyword_tags": { + "name": "saved_keyword_tags", + "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 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "normalized_name": { + "name": "normalized_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "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": { + "saved_keyword_tags_project_normalized_name_idx": { + "name": "saved_keyword_tags_project_normalized_name_idx", + "columns": [ + "project_id", + "normalized_name" + ], + "isUnique": true + }, + "saved_keyword_tags_project_name_idx": { + "name": "saved_keyword_tags_project_name_idx", + "columns": [ + "project_id", + "name" + ], + "isUnique": false + } + }, + "foreignKeys": { + "saved_keyword_tags_project_id_projects_id_fk": { + "name": "saved_keyword_tags_project_id_projects_id_fk", + "tableFrom": "saved_keyword_tags", + "tableTo": "projects", + "columnsFrom": [ + "project_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": {} + }, + "jwks": { + "name": "jwks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "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": {} + }, + "oauth_access_token": { + "name": "oauth_access_token", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_id": { + "name": "refresh_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "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 + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "oauth_access_token_token_unique": { + "name": "oauth_access_token_token_unique", + "columns": [ + "token" + ], + "isUnique": true + } + }, + "foreignKeys": { + "oauth_access_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_access_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_session_id_session_id_fk": { + "name": "oauth_access_token_session_id_session_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_access_token_user_id_user_id_fk": { + "name": "oauth_access_token_user_id_user_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_refresh_id_oauth_refresh_token_id_fk": { + "name": "oauth_access_token_refresh_id_oauth_refresh_token_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_refresh_token", + "columnsFrom": [ + "refresh_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_client": { + "name": "oauth_client", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "disabled": { + "name": "disabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "skip_consent": { + "name": "skip_consent", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enable_end_session": { + "name": "enable_end_session", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "contacts": { + "name": "contacts", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tos": { + "name": "tos", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "software_id": { + "name": "software_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "software_version": { + "name": "software_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "software_statement": { + "name": "software_statement", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "post_logout_redirect_uris": { + "name": "post_logout_redirect_uris", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "grant_types": { + "name": "grant_types", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "response_types": { + "name": "response_types", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "public": { + "name": "public", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "require_pkce": { + "name": "require_pkce", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "oauth_client_client_id_unique": { + "name": "oauth_client_client_id_unique", + "columns": [ + "client_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "oauth_client_user_id_user_id_fk": { + "name": "oauth_client_user_id_user_id_fk", + "tableFrom": "oauth_client", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_consent": { + "name": "oauth_consent", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_consent_client_id_oauth_client_client_id_fk": { + "name": "oauth_consent_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consent_user_id_user_id_fk": { + "name": "oauth_consent_user_id_user_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_refresh_token": { + "name": "oauth_refresh_token", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "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 + }, + "revoked": { + "name": "revoked", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auth_time": { + "name": "auth_time", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_refresh_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_refresh_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_token_session_id_session_id_fk": { + "name": "oauth_refresh_token_session_id_session_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_refresh_token_user_id_user_id_fk": { + "name": "oauth_refresh_token_user_id_user_id_fk", + "tableFrom": "oauth_refresh_token", + "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))" + }, + "analytics_opted_out": { + "name": "analytics_opted_out", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "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 9b1b6f8..08c7250 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -92,6 +92,20 @@ "when": 1778113978173, "tag": "0012_closed_impossible_man", "breakpoints": true + }, + { + "idx": 13, + "version": "6", + "when": 1778532548655, + "tag": "0013_fat_network", + "breakpoints": true + }, + { + "idx": 14, + "version": "6", + "when": 1778750000000, + "tag": "0014_tag_color", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/client/features/domain/components/DifficultyBadge.tsx b/src/client/features/domain/components/DifficultyBadge.tsx index f3ee7c2..26be5ee 100644 --- a/src/client/features/domain/components/DifficultyBadge.tsx +++ b/src/client/features/domain/components/DifficultyBadge.tsx @@ -2,12 +2,17 @@ import { scoreTierClass } from "@/client/features/keywords/utils"; export function DifficultyBadge({ value }: { value: number | null }) { if (value == null) { - return -; + return ( + + — + + ); } - return ( {value} diff --git a/src/client/features/keywords/page/KeywordResearchDesktopTable.tsx b/src/client/features/keywords/page/KeywordResearchDesktopTable.tsx index acc7ed7..0bdfc79 100644 --- a/src/client/features/keywords/page/KeywordResearchDesktopTable.tsx +++ b/src/client/features/keywords/page/KeywordResearchDesktopTable.tsx @@ -16,7 +16,8 @@ import { type SortDir, type SortField, } from "@/client/features/keywords/components"; -import { formatNumber, scoreTierClass } from "@/client/features/keywords/utils"; +import { DifficultyBadge } from "@/client/features/domain/components/DifficultyBadge"; +import { formatNumber } from "@/client/features/keywords/utils"; import type { KeywordResearchRow } from "@/types/keywords"; import { EmptyFilterResults } from "./keywordResearchDesktopFilters"; @@ -156,7 +157,7 @@ export function KeywordResearchDesktopTable({ className="justify-end" /> ), - cell: ({ getValue }) => , + cell: ({ getValue }) => , meta: { headerClassName: "text-right", cellClassName: "text-right" }, }), keywordColumnHelper.accessor("intent", { @@ -214,15 +215,3 @@ export function KeywordResearchDesktopTable({ ); } - -function ScoreCell({ value }: { value: number | null }) { - if (value == null) return null; - const tierClass = scoreTierClass(value); - return ( - - {value} - - ); -} diff --git a/src/client/features/saved-keywords/ManageTagRow.tsx b/src/client/features/saved-keywords/ManageTagRow.tsx new file mode 100644 index 0000000..81000af --- /dev/null +++ b/src/client/features/saved-keywords/ManageTagRow.tsx @@ -0,0 +1,103 @@ +import { Pencil, Trash2 } from "lucide-react"; +import { useState } from "react"; +import { + resolveTagColor, + TAG_COLOR_KEYS, + tagSwatchClass, + type TagColorKey, +} from "@/shared/tag-colors"; +import type { SavedKeywordTagSummary } from "@/types/keywords"; + +export function ManageTagRow({ + tag, + isBusy, + onSave, + onDelete, + onCancel, +}: { + tag: SavedKeywordTagSummary; + isBusy: boolean; + onSave: (input: { name?: string; color?: TagColorKey | null }) => void; + onDelete: () => void; + onCancel: () => void; +}) { + const [name, setName] = useState(tag.name); + const currentColor = resolveTagColor(tag); + const [color, setColor] = useState(currentColor); + const nameChanged = name.trim() !== tag.name && name.trim().length > 0; + const colorChanged = color !== currentColor; + const canSave = (nameChanged || colorChanged) && !isBusy; + + return ( +
+
+ +
+ + setName(event.target.value)} + className="min-w-0 flex-1 rounded border border-base-300 bg-base-100 px-2 py-1 text-sm outline-none focus:border-primary" + /> +
+
+ +
+ +
+ {TAG_COLOR_KEYS.map((key) => ( +
+
+ +
+ +
+ + +
+
+
+ ); +} diff --git a/src/client/features/saved-keywords/SavedKeywordsBulkActionBar.tsx b/src/client/features/saved-keywords/SavedKeywordsBulkActionBar.tsx new file mode 100644 index 0000000..c52ba70 --- /dev/null +++ b/src/client/features/saved-keywords/SavedKeywordsBulkActionBar.tsx @@ -0,0 +1,152 @@ +import { + ChevronDown, + Copy, + Download, + FileDown, + Loader2, + Sheet, + Tags, + Trash2, + X, +} from "lucide-react"; +import type { ReactNode } from "react"; + +export function SavedKeywordsBulkActionBar({ + selectedCount, + onCopy, + onOpenTags, + onExportCsv, + onExportSheets, + onDelete, + onClear, + exportingSelection, +}: { + selectedCount: number; + onCopy: () => void; + onOpenTags: () => void; + onExportCsv: () => void; + onExportSheets: () => void; + onDelete: () => void; + onClear: () => void; + exportingSelection: "csv" | "sheets" | null; +}) { + if (selectedCount === 0) return null; + const exportBusy = exportingSelection != null; + + return ( +
+
+
+ + {selectedCount} + selected +
+ +
+ } + onClick={onOpenTags} + > + Tag + + +
+ +
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+
+
+ +
+ +
+
+
+ ); +} + +function ActionButton({ + icon, + children, + onClick, + disabled, +}: { + icon: ReactNode; + children: ReactNode; + onClick: () => void; + disabled?: boolean; +}) { + return ( + + ); +} diff --git a/src/client/features/saved-keywords/SavedKeywordsBulkTagsModal.tsx b/src/client/features/saved-keywords/SavedKeywordsBulkTagsModal.tsx new file mode 100644 index 0000000..7afebb6 --- /dev/null +++ b/src/client/features/saved-keywords/SavedKeywordsBulkTagsModal.tsx @@ -0,0 +1,322 @@ +import { Check, Loader2, Plus, Search, X } from "lucide-react"; +import { useMemo, useRef, useState } from "react"; +import { Modal } from "@/client/components/Modal"; +import { resolveTagColor, tagDotClass } from "@/shared/tag-colors"; +import type { SavedKeywordTag, SavedKeywordTagSummary } from "@/types/keywords"; +import { TagChip } from "./TagChip"; + +type Mode = "add" | "remove"; + +export function SavedKeywordsBulkTagsModal({ + availableTags, + selectedCount, + selectedRowTags, + isPending, + onClose, + onApply, +}: { + availableTags: SavedKeywordTagSummary[]; + selectedCount: number; + /** Tags currently attached to the selected rows (deduped). Used to show + * initial state and to compute which existing tags can be removed. */ + selectedRowTags: SavedKeywordTag[]; + isPending: boolean; + onClose: () => void; + onApply: (input: { addTags?: string[]; removeTagIds?: string[] }) => void; +}) { + const [mode, setMode] = useState("add"); + const [query, setQuery] = useState(""); + const [addNames, setAddNames] = useState([]); + const [removeIds, setRemoveIds] = useState([]); + const inputRef = useRef(null); + + const normalizedAddSet = useMemo( + () => new Set(addNames.map((name) => name.toLocaleLowerCase())), + [addNames], + ); + + const availableByNormalized = useMemo(() => { + const map = new Map(); + for (const tag of availableTags) { + map.set(tag.normalizedName, tag); + } + return map; + }, [availableTags]); + + const filteredAvailable = useMemo(() => { + const q = query.trim().toLocaleLowerCase(); + if (!q) return availableTags; + return availableTags.filter((tag) => tag.normalizedName.includes(q)); + }, [availableTags, query]); + + const trimmedQuery = query.trim(); + const queryNormalized = trimmedQuery.toLocaleLowerCase(); + const showCreate = + mode === "add" && + trimmedQuery.length > 0 && + !availableByNormalized.has(queryNormalized) && + !normalizedAddSet.has(queryNormalized); + + const canApply = !isPending && (addNames.length > 0 || removeIds.length > 0); + + const handleToggleAdd = (tag: SavedKeywordTagSummary) => { + setAddNames((current) => + normalizedAddSet.has(tag.normalizedName) + ? current.filter( + (name) => name.toLocaleLowerCase() !== tag.normalizedName, + ) + : [...current, tag.name], + ); + setRemoveIds((current) => current.filter((id) => id !== tag.id)); + }; + + const handleCreate = () => { + if (!trimmedQuery) return; + setAddNames((current) => + current.some((name) => name.toLocaleLowerCase() === queryNormalized) + ? current + : [...current, trimmedQuery], + ); + setQuery(""); + inputRef.current?.focus(); + }; + + const handleToggleRemove = (tag: SavedKeywordTag) => { + setRemoveIds((current) => + current.includes(tag.id) + ? current.filter((id) => id !== tag.id) + : [...current, tag.id], + ); + setAddNames((current) => + current.filter((name) => name.toLocaleLowerCase() !== tag.normalizedName), + ); + }; + + return ( + +
+
+

+ Update tags +

+

+ Apply or remove tags across {selectedCount} selected keyword + {selectedCount !== 1 ? "s" : ""}. +

+
+ +
+ setMode("add")} + label="Add tags" + count={addNames.length} + /> + setMode("remove")} + label="Remove tags" + count={removeIds.length} + disabled={selectedRowTags.length === 0} + /> +
+ + {mode === "add" ? ( +
+ {addNames.length > 0 ? ( +
+ {addNames.map((name) => { + const existing = availableByNormalized.get( + name.toLocaleLowerCase(), + ); + const tag = existing ?? { + id: `new:${name}`, + name, + normalizedName: name.toLocaleLowerCase(), + color: null, + }; + return ( + + setAddNames((current) => + current.filter( + (existingName) => existingName !== name, + ), + ) + } + trailing={} + title="Remove from selection" + /> + ); + })} +
+ ) : null} + + + +
+ {showCreate ? ( + + ) : null} + + {filteredAvailable.length === 0 && !showCreate ? ( +
+ {availableTags.length === 0 + ? "No tags yet. Type a name above to create one." + : "No tags match that search."} +
+ ) : null} + + {filteredAvailable.map((tag) => { + const checked = normalizedAddSet.has(tag.normalizedName); + const color = resolveTagColor(tag); + return ( + + ); + })} +
+
+ ) : ( +
+ {selectedRowTags.length === 0 ? ( +
+ The selected keywords don't have any tags to remove. +
+ ) : ( +
+ {selectedRowTags.map((tag) => { + const checked = removeIds.includes(tag.id); + return ( + handleToggleRemove(tag)} + selected={checked} + trailing={checked ? : null} + title={checked ? "Will be removed" : "Click to remove"} + /> + ); + })} +
+ )} + {removeIds.length > 0 ? ( +

+ {removeIds.length} tag{removeIds.length !== 1 ? "s" : ""} will + be detached from the selected keywords. +

+ ) : null} +
+ )} + +
+ + +
+
+
+ ); +} + +function SegmentButton({ + active, + onClick, + label, + count, + disabled, +}: { + active: boolean; + onClick: () => void; + label: string; + count: number; + disabled?: boolean; +}) { + return ( + + ); +} diff --git a/src/client/features/saved-keywords/SavedKeywordsFilterPanel.tsx b/src/client/features/saved-keywords/SavedKeywordsFilterPanel.tsx new file mode 100644 index 0000000..4b45a1b --- /dev/null +++ b/src/client/features/saved-keywords/SavedKeywordsFilterPanel.tsx @@ -0,0 +1,282 @@ +import { Minus, Plus, RotateCcw, X } from "lucide-react"; +import { useState, type KeyboardEvent } from "react"; +import type { SavedKeywordsFilterValues } from "./savedKeywordsFilterTypes"; +import type { SavedKeywordsFilterForm } from "./useSavedKeywordsFilters"; + +export function SavedKeywordsFilterPanel({ + form, + activeFilterCount, + onReset, +}: { + form: SavedKeywordsFilterForm; + activeFilterCount: number; + onReset: () => void; +}) { + return ( +
+
+
+

Refine results

+ {activeFilterCount > 0 ? ( + + {activeFilterCount} active + + ) : null} +
+ +
+ +
+ + +
+ +
+ + + +
+
+ ); +} + +type TermsVariant = "include" | "exclude"; + +const VARIANT_STYLES: Record< + TermsVariant, + { icon: typeof Plus; chip: string; iconBg: string } +> = { + include: { + icon: Plus, + chip: "tag-chip-emerald ring-1 ring-inset", + iconBg: "tag-chip-emerald ring-1 ring-inset", + }, + exclude: { + icon: Minus, + chip: "tag-chip-rose ring-1 ring-inset", + iconBg: "tag-chip-rose ring-1 ring-inset", + }, +}; + +function splitTerms(value: string): string[] { + return value + .split(/[,+]/) + .map((term) => term.trim()) + .filter(Boolean); +} + +function joinTerms(terms: string[]): string { + return terms.join(", "); +} + +function TermsTokenInput({ + form, + name, + label, + variant, + placeholder, +}: { + form: SavedKeywordsFilterForm; + name: "include" | "exclude"; + label: string; + variant: TermsVariant; + placeholder: string; +}) { + const [draft, setDraft] = useState(""); + const styles = VARIANT_STYLES[variant]; + const Icon = styles.icon; + + return ( +
+
+ + + +

+ {label} +

+
+ + {(field) => { + const terms = splitTerms(field.state.value); + const commit = (next: string[]) => { + field.handleChange(joinTerms([...new Set(next)])); + }; + const addFromDraft = () => { + const parsed = splitTerms(draft); + if (parsed.length > 0) { + commit([...terms, ...parsed]); + setDraft(""); + } + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Enter" || event.key === ",") { + event.preventDefault(); + addFromDraft(); + } else if ( + event.key === "Backspace" && + draft.length === 0 && + terms.length > 0 + ) { + commit(terms.slice(0, -1)); + } + }; + return ( +
+ {terms.map((term) => ( + + {term} + + + ))} + setDraft(event.target.value)} + onKeyDown={handleKeyDown} + onBlur={addFromDraft} + placeholder={terms.length === 0 ? placeholder : ""} + className="min-w-[6rem] flex-1 bg-transparent text-xs outline-none placeholder:text-base-content/40" + /> +
+ ); + }} +
+
+ ); +} + +type RangeFieldName = Extract< + keyof SavedKeywordsFilterValues, + "minVol" | "maxVol" | "minCpc" | "maxCpc" | "minKd" | "maxKd" +>; + +function FilterRangeInputs({ + form, + title, + minName, + maxName, + step, + min, + max, +}: { + form: SavedKeywordsFilterForm; + title: string; + minName: Extract; + maxName: Extract; + step?: string; + min?: number; + max?: number; +}) { + return ( +
+

+ {title} +

+
+ + +
+
+ ); +} + +function CompactRangeInput({ + form, + name, + placeholder, + step, + min, + max, +}: { + form: SavedKeywordsFilterForm; + name: RangeFieldName; + placeholder: string; + step?: string; + min?: number; + max?: number; +}) { + return ( + + {(field) => ( + field.handleChange(event.target.value)} + /> + )} + + ); +} diff --git a/src/client/features/saved-keywords/SavedKeywordsFilters.tsx b/src/client/features/saved-keywords/SavedKeywordsFilters.tsx new file mode 100644 index 0000000..c60c87f --- /dev/null +++ b/src/client/features/saved-keywords/SavedKeywordsFilters.tsx @@ -0,0 +1,76 @@ +import { SlidersHorizontal } from "lucide-react"; +import { SavedKeywordsFilterPanel } from "./SavedKeywordsFilterPanel"; +import { SavedKeywordsTagFilter } from "./SavedKeywordsTagFilter"; +import type { TagColorKey } from "@/shared/tag-colors"; +import type { SavedKeywordTagSummary } from "@/types/keywords"; +import type { SavedKeywordsFilterForm } from "./useSavedKeywordsFilters"; + +export function SavedKeywordsFilters({ + filtersForm, + activeFilterCount, + showFilters, + onToggleFilters, + onResetAllFilters, + availableTags, + selectedTagIds, + busyTagIds, + onToggleTagFilter, + onClearTagSelection, + onUpdateTag, + onDeleteTag, +}: { + filtersForm: SavedKeywordsFilterForm; + activeFilterCount: number; + showFilters: boolean; + onToggleFilters: () => void; + onResetAllFilters: () => void; + availableTags: SavedKeywordTagSummary[]; + selectedTagIds: string[]; + busyTagIds: Set; + onToggleTagFilter: (tagId: string) => void; + onClearTagSelection: () => void; + onUpdateTag: (input: { + tagId: string; + name?: string; + color?: TagColorKey | null; + }) => void; + onDeleteTag: (tagId: string) => void; +}) { + return ( + <> +
+ + +
+ + {showFilters ? ( + + ) : null} + + ); +} diff --git a/src/client/features/saved-keywords/SavedKeywordsHeader.tsx b/src/client/features/saved-keywords/SavedKeywordsHeader.tsx new file mode 100644 index 0000000..fff85a0 --- /dev/null +++ b/src/client/features/saved-keywords/SavedKeywordsHeader.tsx @@ -0,0 +1,63 @@ +import { ChevronDown, Download, FileDown, Loader2, Sheet } from "lucide-react"; + +export function SavedKeywordsHeader({ + totalCount, + exporting, + onExportCsv, + onExportSheets, +}: { + totalCount: number; + exporting: "csv" | "sheets" | null; + onExportCsv: () => void; + onExportSheets: () => void; +}) { + const disabled = totalCount === 0 || exporting != null; + + return ( +
+
+

Saved Keywords

+

+ Save keyword ideas from research, organize them with tags, and revisit + when you're ready to act. +

+
+ +
+ +
    +
  • + +
  • +
  • + +
  • +
+
+
+ ); +} diff --git a/src/client/features/saved-keywords/SavedKeywordsModals.tsx b/src/client/features/saved-keywords/SavedKeywordsModals.tsx new file mode 100644 index 0000000..38cd693 --- /dev/null +++ b/src/client/features/saved-keywords/SavedKeywordsModals.tsx @@ -0,0 +1,54 @@ +import { AlertCircle, Loader2 } from "lucide-react"; +import { Modal } from "@/client/components/Modal"; + +export function RemoveSavedKeywordsError({ message }: { message: string }) { + return ( +
+ + {message} +
+ ); +} + +export function DeleteSavedKeywordsModal({ + selectedCount, + isPending, + onClose, + onConfirm, +}: { + selectedCount: number; + isPending: boolean; + onClose: () => void; + onConfirm: () => void; +}) { + return ( + +

+ Delete keywords? +

+

+ This will permanently delete {selectedCount} saved keyword + {selectedCount !== 1 ? "s" : ""}. +

+
+ + +
+
+ ); +} diff --git a/src/client/features/saved-keywords/SavedKeywordsPagination.tsx b/src/client/features/saved-keywords/SavedKeywordsPagination.tsx new file mode 100644 index 0000000..ecc31f4 --- /dev/null +++ b/src/client/features/saved-keywords/SavedKeywordsPagination.tsx @@ -0,0 +1,86 @@ +import { ChevronLeft, ChevronRight, Loader2 } from "lucide-react"; +import { SAVED_KEYWORD_PAGE_SIZES } from "./savedKeywordsUtils"; + +export function SavedKeywordsPagination({ + page, + pageSize, + totalCount, + isLoading, + onPageChange, + onPageSizeChange, +}: { + page: number; + pageSize: (typeof SAVED_KEYWORD_PAGE_SIZES)[number]; + totalCount: number; + isLoading: boolean; + onPageChange: (page: number) => void; + onPageSizeChange: ( + pageSize: (typeof SAVED_KEYWORD_PAGE_SIZES)[number], + ) => void; +}) { + const totalPages = Math.max(1, Math.ceil(totalCount / pageSize)); + const start = totalCount === 0 ? 0 : (page - 1) * pageSize + 1; + const end = Math.min(totalCount, page * pageSize); + + return ( +
+
+ + {start.toLocaleString()}-{end.toLocaleString()} of{" "} + {totalCount.toLocaleString()} + + {isLoading ? : null} +
+
+ +
+ + Page {page.toLocaleString()} of {totalPages.toLocaleString()} + +
+ + +
+
+
+
+ ); +} + +function parsePageSize( + value: string, +): (typeof SAVED_KEYWORD_PAGE_SIZES)[number] { + const parsed = Number(value); + return SAVED_KEYWORD_PAGE_SIZES.find((size) => size === parsed) ?? 50; +} diff --git a/src/client/features/saved-keywords/SavedKeywordsStatus.tsx b/src/client/features/saved-keywords/SavedKeywordsStatus.tsx new file mode 100644 index 0000000..4865c24 --- /dev/null +++ b/src/client/features/saved-keywords/SavedKeywordsStatus.tsx @@ -0,0 +1,19 @@ +import { Loader2 } from "lucide-react"; + +export function SavedKeywordsStatus({ + totalCount, + isFetching, +}: { + totalCount: number; + isFetching: boolean; +}) { + return ( +
+ + {totalCount.toLocaleString()} saved keyword + {totalCount === 1 ? "" : "s"} + + {isFetching ? : null} +
+ ); +} diff --git a/src/client/features/saved-keywords/SavedKeywordsTable.tsx b/src/client/features/saved-keywords/SavedKeywordsTable.tsx new file mode 100644 index 0000000..36df602 --- /dev/null +++ b/src/client/features/saved-keywords/SavedKeywordsTable.tsx @@ -0,0 +1,203 @@ +import { + createColumnHelper, + type ColumnDef, + type OnChangeFn, + type RowSelectionState, + type SortingState, +} from "@tanstack/react-table"; +import { Search } from "lucide-react"; +import { useMemo } from "react"; +import { + AppDataTable, + makeSelectionColumn, + useAppTable, + useSelectionAnchor, +} from "@/client/components/table/AppDataTable"; +import { SortableHeader } from "@/client/components/table/SortableHeader"; +import { DifficultyBadge } from "@/client/features/domain/components/DifficultyBadge"; +import { IntentBadge } from "@/client/features/keywords/components"; +import type { KeywordIntent, SavedKeywordRow } from "@/types/keywords"; +import { TagChip } from "./TagChip"; +import { + formatSavedKeywordDate, + formatSavedKeywordNumber, +} from "./savedKeywordsUtils"; + +const columnHelper = createColumnHelper(); + +export function SavedKeywordsTable({ + rows, + rowSelection, + sorting, + isLoading, + hasActiveFilters, + onRowSelectionChange, + onSortingChange, +}: { + rows: SavedKeywordRow[]; + rowSelection: RowSelectionState; + sorting: SortingState; + isLoading: boolean; + hasActiveFilters: boolean; + onRowSelectionChange: OnChangeFn; + onSortingChange: OnChangeFn; +}) { + const selectAnchorRef = useSelectionAnchor(); + const columns = useMemo[]>( + () => [ + makeSelectionColumn(selectAnchorRef), + columnHelper.accessor("keyword", { + header: ({ column }) => ( + + ), + cell: ({ getValue }) => ( + {getValue()} + ), + }), + columnHelper.accessor("searchVolume", { + header: ({ column }) => ( + + ), + cell: ({ getValue }) => formatSavedKeywordNumber(getValue()), + }), + columnHelper.accessor("cpc", { + header: ({ column }) => , + cell: ({ getValue }) => { + const value = getValue(); + return value == null ? "-" : `$${value.toFixed(2)}`; + }, + }), + columnHelper.accessor("competition", { + header: ({ column }) => ( + + ), + cell: ({ getValue }) => { + const value = getValue(); + return value == null ? "-" : value.toFixed(2); + }, + }), + columnHelper.accessor("keywordDifficulty", { + header: ({ column }) => ( + + ), + cell: ({ getValue }) => , + }), + columnHelper.accessor("intent", { + header: () => "Intent", + cell: ({ getValue }) => ( + + ), + enableSorting: false, + }), + columnHelper.display({ + id: "tags", + header: () => "Tags", + cell: ({ row }) => , + enableSorting: false, + meta: { cellClassName: "min-w-40 max-w-64" }, + }), + columnHelper.accessor("fetchedAt", { + header: ({ column }) => ( + + ), + cell: ({ getValue }) => ( + + {formatSavedKeywordDate(getValue())} + + ), + }), + ], + [selectAnchorRef], + ); + const table = useAppTable({ + data: rows, + columns, + state: { rowSelection, sorting }, + onRowSelectionChange, + onSortingChange, + getRowId: (row) => row.id, + enableRowSelection: true, + manualSorting: true, + }); + + return ( + } + empty={} + /> + ); +} + +function normalizeIntent(value: string | null): KeywordIntent { + switch (value) { + case "informational": + case "commercial": + case "transactional": + case "navigational": + case "unknown": + return value; + default: + return "unknown"; + } +} + +function TagList({ tags }: { tags: SavedKeywordRow["tags"] }) { + if (tags.length === 0) { + return -; + } + return ( +
+ {tags.map((tag) => ( + + ))} +
+ ); +} + +function SavedKeywordsSkeleton() { + return ( +
+
+ {Array.from({ length: 8 }).map((_, index) => ( +
+
+
+
+
+
+
+
+
+
+ ))} +
+ ); +} + +function SavedKeywordsEmptyState({ + hasActiveFilters, +}: { + hasActiveFilters: boolean; +}) { + return ( +
+ +

+ {hasActiveFilters + ? "No saved keywords match the current filters." + : "No saved keywords yet. Use the Keyword Research page to find and save keywords."} +

+
+ ); +} diff --git a/src/client/features/saved-keywords/SavedKeywordsTagFilter.tsx b/src/client/features/saved-keywords/SavedKeywordsTagFilter.tsx new file mode 100644 index 0000000..64926ce --- /dev/null +++ b/src/client/features/saved-keywords/SavedKeywordsTagFilter.tsx @@ -0,0 +1,318 @@ +import { + Check, + ChevronDown, + MoreHorizontal, + Search, + Tag as TagIcon, + X, +} from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { + resolveTagColor, + tagDotClass, + type TagColorKey, +} from "@/shared/tag-colors"; +import type { SavedKeywordTagSummary } from "@/types/keywords"; +import { ManageTagRow } from "./ManageTagRow"; +import { TagChip } from "./TagChip"; + +export function SavedKeywordsTagFilter({ + availableTags, + selectedTagIds, + onToggleTagFilter, + onClearSelection, + onUpdateTag, + onDeleteTag, + busyTagIds, +}: { + availableTags: SavedKeywordTagSummary[]; + selectedTagIds: string[]; + onToggleTagFilter: (tagId: string) => void; + onClearSelection: () => void; + onUpdateTag: (input: { + tagId: string; + name?: string; + color?: TagColorKey | null; + }) => void; + onDeleteTag: (tagId: string) => void; + busyTagIds: Set; +}) { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const [managingTagId, setManagingTagId] = useState(null); + const containerRef = useRef(null); + + useEffect(() => { + if (!open) return; + const handleClick = (event: MouseEvent) => { + const target = event.target; + if ( + target instanceof Node && + containerRef.current && + containerRef.current.contains(target) + ) { + return; + } + setOpen(false); + setManagingTagId(null); + }; + const handleKey = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setOpen(false); + setManagingTagId(null); + } + }; + document.addEventListener("mousedown", handleClick); + document.addEventListener("keydown", handleKey); + return () => { + document.removeEventListener("mousedown", handleClick); + document.removeEventListener("keydown", handleKey); + }; + }, [open]); + + const filteredTags = useMemo(() => { + const q = query.trim().toLocaleLowerCase(); + if (!q) return availableTags; + return availableTags.filter((tag) => tag.normalizedName.includes(q)); + }, [availableTags, query]); + + const selectedTags = availableTags.filter((tag) => + selectedTagIds.includes(tag.id), + ); + const hasSelection = selectedTagIds.length > 0; + + return ( +
+ + + {selectedTags.length > 0 ? ( +
+ {selectedTags.map((tag) => ( + onToggleTagFilter(tag.id)} + trailing={} + title="Remove filter" + /> + ))} + +
+ ) : null} + + {open ? ( + { + onUpdateTag({ tagId, ...input }); + setManagingTagId(null); + }} + onDeleteTag={(tagId) => { + onDeleteTag(tagId); + setManagingTagId(null); + }} + onClearSelection={onClearSelection} + /> + ) : null} +
+ ); +} + +function TagFilterPopover({ + availableTags, + filteredTags, + selectedTagIds, + query, + managingTagId, + busyTagIds, + onQueryChange, + onToggleTagFilter, + onStartManaging, + onUpdateTag, + onDeleteTag, + onClearSelection, +}: { + availableTags: SavedKeywordTagSummary[]; + filteredTags: SavedKeywordTagSummary[]; + selectedTagIds: string[]; + query: string; + managingTagId: string | null; + busyTagIds: Set; + onQueryChange: (value: string) => void; + onToggleTagFilter: (tagId: string) => void; + onStartManaging: (tagId: string | null) => void; + onUpdateTag: ( + tagId: string, + input: { name?: string; color?: TagColorKey | null }, + ) => void; + onDeleteTag: (tagId: string) => void; + onClearSelection: () => void; +}) { + return ( +
+
+ +
+ +
+ {filteredTags.length === 0 ? ( +
+ {availableTags.length === 0 + ? "No tags yet. Add tags from a selection of keywords." + : "No tags match that search."} +
+ ) : null} + + {filteredTags.map((tag) => ( + onToggleTagFilter(tag.id)} + onStartManaging={onStartManaging} + onUpdate={(input) => onUpdateTag(tag.id, input)} + onDelete={() => onDeleteTag(tag.id)} + /> + ))} +
+ + {selectedTagIds.length > 0 ? ( +
+ + {selectedTagIds.length} selected + + +
+ ) : null} +
+ ); +} + +function TagFilterRow({ + tag, + checked, + isManaging, + isBusy, + onToggle, + onStartManaging, + onUpdate, + onDelete, +}: { + tag: SavedKeywordTagSummary; + checked: boolean; + isManaging: boolean; + isBusy: boolean; + onToggle: () => void; + onStartManaging: (tagId: string | null) => void; + onUpdate: (input: { name?: string; color?: TagColorKey | null }) => void; + onDelete: () => void; +}) { + const color = resolveTagColor(tag); + return ( +
+
+ + +
+ + {isManaging ? ( + onStartManaging(null)} + /> + ) : null} +
+ ); +} diff --git a/src/client/features/saved-keywords/TagChip.tsx b/src/client/features/saved-keywords/TagChip.tsx new file mode 100644 index 0000000..7a3a38c --- /dev/null +++ b/src/client/features/saved-keywords/TagChip.tsx @@ -0,0 +1,66 @@ +import type { ReactNode } from "react"; +import { + resolveTagColor, + tagChipClass, + tagDotClass, +} from "@/shared/tag-colors"; +import type { SavedKeywordTag } from "@/types/keywords"; + +type Size = "xs" | "sm" | "md"; + +const SIZE_CLASS: Record = { + xs: "h-5 px-1.5 text-[11px]", + sm: "h-6 px-2 text-xs", + md: "h-7 px-2.5 text-sm", +}; + +export function TagChip({ + tag, + size = "sm", + trailing, + onClick, + selected, + title, +}: { + tag: Pick; + size?: Size; + trailing?: ReactNode; + onClick?: () => void; + selected?: boolean; + title?: string; +}) { + const color = resolveTagColor(tag); + const base = `inline-flex items-center gap-1.5 rounded-md font-medium ${SIZE_CLASS[size]} ${tagChipClass(color)}`; + const interactive = onClick + ? "cursor-pointer hover:brightness-110 transition" + : ""; + const ring = selected ? "ring-2 ring-offset-1 ring-offset-base-100" : ""; + + const content = ( + <> + + {tag.name} + {trailing} + + ); + + if (onClick) { + return ( + + ); + } + return ( + + {content} + + ); +} diff --git a/src/client/features/saved-keywords/savedKeywordsFilterTypes.ts b/src/client/features/saved-keywords/savedKeywordsFilterTypes.ts new file mode 100644 index 0000000..451f606 --- /dev/null +++ b/src/client/features/saved-keywords/savedKeywordsFilterTypes.ts @@ -0,0 +1,89 @@ +export type SavedKeywordsFilterValues = { + include: string; + exclude: string; + minVol: string; + maxVol: string; + minCpc: string; + maxCpc: string; + minKd: string; + maxKd: string; +}; + +export const EMPTY_SAVED_KEYWORDS_FILTERS: SavedKeywordsFilterValues = { + include: "", + exclude: "", + minVol: "", + maxVol: "", + minCpc: "", + maxCpc: "", + minKd: "", + maxKd: "", +}; + +export type AppliedSavedKeywordsFilters = { + includeTerms?: string[]; + excludeTerms?: string[]; + minVolume?: number | null; + maxVolume?: number | null; + minCpc?: number | null; + maxCpc?: number | null; + minDifficulty?: number | null; + maxDifficulty?: number | null; +}; + +function parseTerms(value: string): string[] { + return value + .toLowerCase() + .split(/[,+]/) + .map((term) => term.trim()) + .filter(Boolean); +} + +function clamp(value: number, bounds: { min?: number; max?: number }) { + if (bounds.min != null && value < bounds.min) return bounds.min; + if (bounds.max != null && value > bounds.max) return bounds.max; + return value; +} + +function toIntOrUndef( + value: string, + bounds: { min?: number; max?: number } = {}, +): number | undefined { + if (!value.trim()) return undefined; + const n = Number(value); + if (!Number.isFinite(n)) return undefined; + return Math.trunc(clamp(n, bounds)); +} + +function toFloatOrUndef( + value: string, + bounds: { min?: number; max?: number } = {}, +): number | undefined { + if (!value.trim()) return undefined; + const n = Number(value); + if (!Number.isFinite(n)) return undefined; + return clamp(n, bounds); +} + +export function compileSavedKeywordsFilters( + values: SavedKeywordsFilterValues, +): AppliedSavedKeywordsFilters { + const includeTerms = parseTerms(values.include); + const excludeTerms = parseTerms(values.exclude); + return { + includeTerms: includeTerms.length > 0 ? includeTerms : undefined, + excludeTerms: excludeTerms.length > 0 ? excludeTerms : undefined, + minVolume: toIntOrUndef(values.minVol, { min: 0 }), + maxVolume: toIntOrUndef(values.maxVol, { min: 0 }), + minCpc: toFloatOrUndef(values.minCpc, { min: 0 }), + maxCpc: toFloatOrUndef(values.maxCpc, { min: 0 }), + minDifficulty: toIntOrUndef(values.minKd, { min: 0, max: 100 }), + maxDifficulty: toIntOrUndef(values.maxKd, { min: 0, max: 100 }), + }; +} + +export function countActiveSavedKeywordsFilters( + values: SavedKeywordsFilterValues, +): number { + return Object.values(values).filter((value) => value.trim() !== "").length; +} diff --git a/src/client/features/saved-keywords/savedKeywordsUtils.ts b/src/client/features/saved-keywords/savedKeywordsUtils.ts new file mode 100644 index 0000000..a232b1a --- /dev/null +++ b/src/client/features/saved-keywords/savedKeywordsUtils.ts @@ -0,0 +1,50 @@ +import type { CsvValue } from "@/client/lib/csv"; +import { KEYWORD_RESEARCH_HEADERS } from "@/client/features/keywords/state/keywordControllerActions"; +import type { SavedKeywordRow } from "@/types/keywords"; +import type { GetSavedKeywordsInput } from "@/types/schemas/keywords"; + +export const SAVED_KEYWORD_PAGE_SIZES = [50, 100, 250] as const; +export const SAVED_KEYWORD_EXPORT_HEADERS = [ + ...KEYWORD_RESEARCH_HEADERS, + "Tags", + "Fetched At", +]; + +export function savedKeywordExportRow(row: SavedKeywordRow): CsvValue[] { + return [ + row.keyword, + row.searchVolume ?? "", + row.cpc ?? "", + row.competition ?? "", + row.keywordDifficulty ?? "", + row.intent ?? "", + row.tags.map((tag) => tag.name).join(", "), + row.fetchedAt ?? "", + ]; +} + +export function toSavedKeywordSort( + value: string | undefined, +): GetSavedKeywordsInput["sort"] { + if ( + value === "keyword" || + value === "searchVolume" || + value === "cpc" || + value === "competition" || + value === "keywordDifficulty" || + value === "fetchedAt" + ) { + return value; + } + return "createdAt"; +} + +export function formatSavedKeywordNumber(value: number | null | undefined) { + if (value == null) return "-"; + return new Intl.NumberFormat().format(value); +} + +export function formatSavedKeywordDate(value: string | null | undefined) { + if (!value) return "-"; + return new Date(value).toLocaleDateString(); +} diff --git a/src/client/features/saved-keywords/useSavedKeywordsExport.ts b/src/client/features/saved-keywords/useSavedKeywordsExport.ts new file mode 100644 index 0000000..1c2d0a8 --- /dev/null +++ b/src/client/features/saved-keywords/useSavedKeywordsExport.ts @@ -0,0 +1,142 @@ +import { useMemo, useState } from "react"; +import { toast } from "sonner"; +import { buildCsv, downloadCsv } from "@/client/lib/csv"; +import { getStandardErrorMessage } from "@/client/lib/error-messages"; +import { exportTableToSheets } from "@/client/lib/exportToSheets"; +import { captureClientEvent } from "@/client/lib/posthog"; +import { exportSavedKeywords } from "@/serverFunctions/keywords"; +import type { SavedKeywordRow } from "@/types/keywords"; +import type { ExportSavedKeywordsInput } from "@/types/schemas/keywords"; +import type { AppliedSavedKeywordsFilters } from "./savedKeywordsFilterTypes"; +import { + SAVED_KEYWORD_EXPORT_HEADERS, + savedKeywordExportRow, +} from "./savedKeywordsUtils"; + +export function useSavedKeywordsExport(params: { + projectId: string; + appliedFilters: AppliedSavedKeywordsFilters; + selectedTagIds: string[]; + sort: ExportSavedKeywordsInput["sort"]; + order: ExportSavedKeywordsInput["order"]; +}) { + const [exporting, setExporting] = useState<"csv" | "sheets" | null>(null); + const [exportingSelection, setExportingSelection] = useState< + "csv" | "sheets" | null + >(null); + + const exportInput = useMemo( + () => ({ + projectId: params.projectId, + ...params.appliedFilters, + tagIds: + params.selectedTagIds.length > 0 ? params.selectedTagIds : undefined, + sort: params.sort, + order: params.order, + }), + [ + params.appliedFilters, + params.order, + params.projectId, + params.selectedTagIds, + params.sort, + ], + ); + + const loadFilteredRows = async () => { + const result = await exportSavedKeywords({ data: exportInput }); + return result.rows; + }; + + const exportFilteredCsv = async () => { + setExporting("csv"); + try { + const rows = await loadFilteredRows(); + if (rows.length === 0) { + toast.error("No keywords to export"); + return; + } + downloadKeywordCsv(rows); + captureClientEvent("data:export", { + source_feature: "saved_keywords", + result_count: rows.length, + }); + } catch (error) { + toast.error(getStandardErrorMessage(error, "Could not export CSV")); + } finally { + setExporting(null); + } + }; + + const exportFilteredSheets = async () => { + setExporting("sheets"); + try { + const rows = await loadFilteredRows(); + await exportTableToSheets({ + headers: SAVED_KEYWORD_EXPORT_HEADERS, + rows: rows.map(savedKeywordExportRow), + feature: "saved_keywords", + }); + } catch (error) { + toast.error(getStandardErrorMessage(error, "Could not export to Sheets")); + } finally { + setExporting(null); + } + }; + + const exportSelectionCsv = (selectedRows: SavedKeywordRow[]) => { + if (selectedRows.length === 0) return; + setExportingSelection("csv"); + try { + downloadKeywordCsv(selectedRows); + captureClientEvent("data:export", { + source_feature: "saved_keywords", + result_count: selectedRows.length, + scope: "selection", + }); + } finally { + setExportingSelection(null); + } + }; + + const exportSelectionSheets = async (selectedRows: SavedKeywordRow[]) => { + if (selectedRows.length === 0) return; + setExportingSelection("sheets"); + try { + await exportTableToSheets({ + headers: SAVED_KEYWORD_EXPORT_HEADERS, + rows: selectedRows.map(savedKeywordExportRow), + feature: "saved_keywords", + }); + } catch (error) { + toast.error(getStandardErrorMessage(error, "Could not export to Sheets")); + } finally { + setExportingSelection(null); + } + }; + + return { + exporting, + exportingSelection, + exportFilteredCsv, + exportFilteredSheets, + exportSelectionCsv, + exportSelectionSheets, + }; +} + +function downloadKeywordCsv(rows: SavedKeywordRow[]) { + const csvRows = rows + .map(savedKeywordExportRow) + .map((row) => + row.map((cell, index) => + (index === 2 || index === 3) && typeof cell === "number" + ? cell.toFixed(2) + : cell, + ), + ); + downloadCsv( + "saved-keywords.csv", + buildCsv(SAVED_KEYWORD_EXPORT_HEADERS, csvRows), + ); +} diff --git a/src/client/features/saved-keywords/useSavedKeywordsFilters.ts b/src/client/features/saved-keywords/useSavedKeywordsFilters.ts new file mode 100644 index 0000000..b017b73 --- /dev/null +++ b/src/client/features/saved-keywords/useSavedKeywordsFilters.ts @@ -0,0 +1,36 @@ +import { useForm, useStore } from "@tanstack/react-form"; +import { useCallback } from "react"; +import { + countActiveSavedKeywordsFilters, + EMPTY_SAVED_KEYWORDS_FILTERS, + type SavedKeywordsFilterValues, +} from "./savedKeywordsFilterTypes"; + +const FILTER_KEYS: Array = [ + "include", + "exclude", + "minVol", + "maxVol", + "minCpc", + "maxCpc", + "minKd", + "maxKd", +]; + +export function useSavedKeywordsFilters() { + const filtersForm = useForm({ defaultValues: EMPTY_SAVED_KEYWORDS_FILTERS }); + const values = useStore(filtersForm.store, (s) => s.values); + const activeFilterCount = countActiveSavedKeywordsFilters(values); + + const resetFilters = useCallback(() => { + for (const key of FILTER_KEYS) { + filtersForm.setFieldValue(key, ""); + } + }, [filtersForm]); + + return { filtersForm, values, activeFilterCount, resetFilters }; +} + +export type SavedKeywordsFilterForm = ReturnType< + typeof useSavedKeywordsFilters +>["filtersForm"]; diff --git a/src/client/features/saved-keywords/useTagManage.ts b/src/client/features/saved-keywords/useTagManage.ts new file mode 100644 index 0000000..336c176 --- /dev/null +++ b/src/client/features/saved-keywords/useTagManage.ts @@ -0,0 +1,72 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import { toast } from "sonner"; +import { getStandardErrorMessage } from "@/client/lib/error-messages"; +import { + deleteSavedKeywordTag, + updateSavedKeywordTag, +} from "@/serverFunctions/keywords"; +import type { TagColorKey } from "@/shared/tag-colors"; + +export function useTagManage(projectId: string) { + const queryClient = useQueryClient(); + const [busyTagIds, setBusyTagIds] = useState>(new Set()); + + const markBusy = (tagId: string, busy: boolean) => { + setBusyTagIds((current) => { + const next = new Set(current); + if (busy) next.add(tagId); + else next.delete(tagId); + return next; + }); + }; + + const invalidate = () => + queryClient.invalidateQueries({ queryKey: ["savedKeywords", projectId] }); + + const updateTag = async (input: { + tagId: string; + name?: string; + color?: TagColorKey | null; + }) => { + markBusy(input.tagId, true); + try { + await updateSavedKeywordTag({ + data: { + projectId, + tagId: input.tagId, + name: input.name, + color: input.color ?? undefined, + }, + }); + await invalidate(); + toast.success("Tag updated"); + } catch (error) { + toast.error(getStandardErrorMessage(error, "Could not update tag")); + } finally { + markBusy(input.tagId, false); + } + }; + + const deleteTag = async (tagId: string): Promise => { + markBusy(tagId, true); + try { + await deleteSavedKeywordTag({ data: { projectId, tagId } }); + await invalidate(); + toast.success("Tag deleted"); + return true; + } catch (error) { + toast.error( + getStandardErrorMessage( + error, + "Could not delete tag. Detach it from all keywords and try again.", + ), + ); + return false; + } finally { + markBusy(tagId, false); + } + }; + + return { busyTagIds, updateTag, deleteTag }; +} diff --git a/src/client/styles/app.css b/src/client/styles/app.css index 502c59b..98e65f4 100644 --- a/src/client/styles/app.css +++ b/src/client/styles/app.css @@ -169,34 +169,141 @@ select { @apply border-error/70 bg-error/40 text-base-content/90; } -/* Keyword difficulty score badges */ +/* Tag chip colors — muted background + readable text in both themes. + Text uses darker shades on light theme for contrast, lighter on dark. */ +.tag-chip-slate { + background-color: color-mix(in oklab, #64748b 14%, transparent); + color: #334155; + --tw-ring-color: color-mix(in oklab, #64748b 30%, transparent); +} +.tag-chip-rose { + background-color: color-mix(in oklab, #f43f5e 14%, transparent); + color: #9f1239; + --tw-ring-color: color-mix(in oklab, #f43f5e 30%, transparent); +} +.tag-chip-amber { + background-color: color-mix(in oklab, #f59e0b 16%, transparent); + color: #92400e; + --tw-ring-color: color-mix(in oklab, #f59e0b 32%, transparent); +} +.tag-chip-lime { + background-color: color-mix(in oklab, #84cc16 16%, transparent); + color: #3f6212; + --tw-ring-color: color-mix(in oklab, #84cc16 32%, transparent); +} +.tag-chip-emerald { + background-color: color-mix(in oklab, #10b981 14%, transparent); + color: #065f46; + --tw-ring-color: color-mix(in oklab, #10b981 30%, transparent); +} +.tag-chip-sky { + background-color: color-mix(in oklab, #0ea5e9 14%, transparent); + color: #075985; + --tw-ring-color: color-mix(in oklab, #0ea5e9 30%, transparent); +} +.tag-chip-violet { + background-color: color-mix(in oklab, #8b5cf6 16%, transparent); + color: #5b21b6; + --tw-ring-color: color-mix(in oklab, #8b5cf6 32%, transparent); +} +.tag-chip-fuchsia { + background-color: color-mix(in oklab, #d946ef 14%, transparent); + color: #86198f; + --tw-ring-color: color-mix(in oklab, #d946ef 30%, transparent); +} + +html[data-theme="openseo-dark"] .tag-chip-slate { + color: #cbd5e1; +} +html[data-theme="openseo-dark"] .tag-chip-rose { + color: #fda4af; +} +html[data-theme="openseo-dark"] .tag-chip-amber { + color: #fcd34d; +} +html[data-theme="openseo-dark"] .tag-chip-lime { + color: #d9f99d; +} +html[data-theme="openseo-dark"] .tag-chip-emerald { + color: #6ee7b7; +} +html[data-theme="openseo-dark"] .tag-chip-sky { + color: #7dd3fc; +} +html[data-theme="openseo-dark"] .tag-chip-violet { + color: #c4b5fd; +} +html[data-theme="openseo-dark"] .tag-chip-fuchsia { + color: #f5d0fe; +} + +/* Keyword difficulty score badges — muted/transparent style to match tag chips */ .score-badge { - @apply border text-white; - border-color: color-mix(in oklab, currentColor 26%, transparent); + @apply ring-1 ring-inset; +} + +.score-tier-na { + background-color: color-mix(in oklab, #94a3b8 12%, transparent); + color: #475569; + --tw-ring-color: color-mix(in oklab, #94a3b8 28%, transparent); } .score-tier-1 { - background-color: #22c55e; + background-color: color-mix(in oklab, #10b981 15%, transparent); + color: #065f46; + --tw-ring-color: color-mix(in oklab, #10b981 30%, transparent); } .score-tier-2 { - background-color: #6bd84d; + background-color: color-mix(in oklab, #84cc16 15%, transparent); + color: #3f6212; + --tw-ring-color: color-mix(in oklab, #84cc16 30%, transparent); } .score-tier-3 { - background-color: #eab308; + background-color: color-mix(in oklab, #eab308 16%, transparent); + color: #854d0e; + --tw-ring-color: color-mix(in oklab, #eab308 32%, transparent); } .score-tier-4 { - background-color: #f97316; + background-color: color-mix(in oklab, #f97316 16%, transparent); + color: #9a3412; + --tw-ring-color: color-mix(in oklab, #f97316 32%, transparent); } .score-tier-5 { - background-color: #ef4444; + background-color: color-mix(in oklab, #ef4444 15%, transparent); + color: #991b1b; + --tw-ring-color: color-mix(in oklab, #ef4444 30%, transparent); } .score-tier-6 { - background-color: #dc2626; + background-color: color-mix(in oklab, #b91c1c 18%, transparent); + color: #7f1d1d; + --tw-ring-color: color-mix(in oklab, #b91c1c 32%, transparent); +} + +html[data-theme="openseo-dark"] .score-tier-na { + color: #cbd5e1; +} +html[data-theme="openseo-dark"] .score-tier-1 { + color: #6ee7b7; +} +html[data-theme="openseo-dark"] .score-tier-2 { + color: #bef264; +} +html[data-theme="openseo-dark"] .score-tier-3 { + color: #fde047; +} +html[data-theme="openseo-dark"] .score-tier-4 { + color: #fdba74; +} +html[data-theme="openseo-dark"] .score-tier-5 { + color: #fca5a5; +} +html[data-theme="openseo-dark"] .score-tier-6 { + color: #fda4af; } html[data-theme="openseo-dark"] { @@ -209,35 +316,6 @@ html[data-theme="openseo-dark"] { --trend-tooltip-shadow: oklch(0% 0 0 / 0.35); } -html[data-theme="openseo-dark"] .score-badge { - color: oklch(96% 0.01 95); - filter: saturate(1.26) brightness(1.14); -} - -html[data-theme="openseo-dark"] .score-tier-1 { - background-color: oklch(58% 0.12 148); -} - -html[data-theme="openseo-dark"] .score-tier-2 { - background-color: oklch(60% 0.115 132); -} - -html[data-theme="openseo-dark"] .score-tier-3 { - background-color: oklch(66% 0.11 92); -} - -html[data-theme="openseo-dark"] .score-tier-4 { - background-color: oklch(64% 0.12 56); -} - -html[data-theme="openseo-dark"] .score-tier-5 { - background-color: oklch(62% 0.12 36); -} - -html[data-theme="openseo-dark"] .score-tier-6 { - background-color: oklch(58% 0.11 30); -} - html[data-theme="openseo-dark"] .alert-warning { @apply border-warning/60 bg-warning/20; } diff --git a/src/db/app.schema.ts b/src/db/app.schema.ts index e395011..902cbf5 100644 --- a/src/db/app.schema.ts +++ b/src/db/app.schema.ts @@ -61,6 +61,57 @@ export const savedKeywords = sqliteTable( ], ); +export const savedKeywordTags = sqliteTable( + "saved_keyword_tags", + { + id: text("id").primaryKey(), + projectId: text("project_id") + .notNull() + .references(() => projects.id, { onDelete: "cascade" }), + name: text("name").notNull(), + normalizedName: text("normalized_name").notNull(), + // Palette key (e.g. "blue", "rose"). Null = derive a stable color from the + // tag id at render time. See src/shared/tag-colors.ts. + color: text("color"), + createdAt: text("created_at") + .notNull() + .default(sql`(current_timestamp)`), + }, + (table) => [ + uniqueIndex("saved_keyword_tags_project_normalized_name_idx").on( + table.projectId, + table.normalizedName, + ), + index("saved_keyword_tags_project_name_idx").on( + table.projectId, + table.name, + ), + ], +); + +export const savedKeywordTagAssignments = sqliteTable( + "saved_keyword_tag_assignments", + { + savedKeywordId: text("saved_keyword_id") + .notNull() + .references(() => savedKeywords.id, { onDelete: "cascade" }), + tagId: text("tag_id") + .notNull() + .references(() => savedKeywordTags.id, { onDelete: "cascade" }), + createdAt: text("created_at") + .notNull() + .default(sql`(current_timestamp)`), + }, + (table) => [ + uniqueIndex("saved_keyword_tag_assignments_unique_idx").on( + table.savedKeywordId, + table.tagId, + ), + index("saved_keyword_tag_assignments_keyword_idx").on(table.savedKeywordId), + index("saved_keyword_tag_assignments_tag_idx").on(table.tagId), + ], +); + // Latest cached metrics for a keyword within a project. // This is joined onto savedKeywords when rendering the saved keyword list. export const keywordMetrics = sqliteTable( diff --git a/src/routes/_project/p/$projectId/saved.tsx b/src/routes/_project/p/$projectId/saved.tsx index ee7dcc4..5016aad 100644 --- a/src/routes/_project/p/$projectId/saved.tsx +++ b/src/routes/_project/p/$projectId/saved.tsx @@ -1,358 +1,340 @@ import { createFileRoute } from "@tanstack/react-router"; -import { useState } from "react"; +import { + keepPreviousData, + useMutation, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; +import type { + OnChangeFn, + RowSelectionState, + SortingState, +} from "@tanstack/react-table"; +import { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { SavedKeywordsBulkActionBar } from "@/client/features/saved-keywords/SavedKeywordsBulkActionBar"; +import { SavedKeywordsBulkTagsModal } from "@/client/features/saved-keywords/SavedKeywordsBulkTagsModal"; +import { SavedKeywordsFilters } from "@/client/features/saved-keywords/SavedKeywordsFilters"; +import { SavedKeywordsHeader } from "@/client/features/saved-keywords/SavedKeywordsHeader"; +import { + DeleteSavedKeywordsModal, + RemoveSavedKeywordsError, +} from "@/client/features/saved-keywords/SavedKeywordsModals"; +import { SavedKeywordsPagination } from "@/client/features/saved-keywords/SavedKeywordsPagination"; +import { SavedKeywordsStatus } from "@/client/features/saved-keywords/SavedKeywordsStatus"; +import { SavedKeywordsTable } from "@/client/features/saved-keywords/SavedKeywordsTable"; +import { compileSavedKeywordsFilters } from "@/client/features/saved-keywords/savedKeywordsFilterTypes"; +import { + toSavedKeywordSort, + type SAVED_KEYWORD_PAGE_SIZES, +} from "@/client/features/saved-keywords/savedKeywordsUtils"; +import { useSavedKeywordsExport } from "@/client/features/saved-keywords/useSavedKeywordsExport"; +import { useSavedKeywordsFilters } from "@/client/features/saved-keywords/useSavedKeywordsFilters"; +import { useTagManage } from "@/client/features/saved-keywords/useTagManage"; +import { getStandardErrorMessage } from "@/client/lib/error-messages"; +import { captureClientEvent } from "@/client/lib/posthog"; import { getSavedKeywords, removeSavedKeywords, + updateSavedKeywordTags, } from "@/serverFunctions/keywords"; -import { - Download, - Search, - Loader2, - AlertCircle, - Trash2, - Copy, -} from "lucide-react"; -import { ExportToSheetsButton } from "@/client/components/table/ExportToSheetsButton"; -import { KEYWORD_RESEARCH_HEADERS } from "@/client/features/keywords/state/keywordControllerActions"; -import { buildCsv, type CsvValue, downloadCsv } from "@/client/lib/csv"; -import { getStandardErrorMessage } from "@/client/lib/error-messages"; -import { captureClientEvent } from "@/client/lib/posthog"; +import type { SavedKeywordTag } from "@/types/keywords"; export const Route = createFileRoute("/_project/p/$projectId/saved")({ component: SavedKeywordsPage, }); -type SavedKeyword = { - id: string; - keyword: string; - searchVolume: number | null; - cpc: number | null; - competition: number | null; - keywordDifficulty: number | null; - intent: string | null; - fetchedAt: string | null; -}; +const FILTER_DEBOUNCE_MS = 350; function SavedKeywordsPage() { const { projectId } = Route.useParams(); const queryClient = useQueryClient(); + const [selectedTagIds, setSelectedTagIds] = useState([]); + const [showFilters, setShowFilters] = useState(false); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = + useState<(typeof SAVED_KEYWORD_PAGE_SIZES)[number]>(50); + const [sorting, setSorting] = useState([ + { id: "fetchedAt", desc: true }, + ]); + const [rowSelection, setRowSelection] = useState({}); const [removeError, setRemoveError] = useState(null); - const [selected, setSelected] = useState>(new Set()); const [showConfirm, setShowConfirm] = useState(false); - const [deleting, setDeleting] = useState(false); + const [showTagModal, setShowTagModal] = useState(false); - const { data: savedKeywordsData, isLoading } = useQuery({ - queryKey: ["savedKeywords", projectId], - queryFn: () => getSavedKeywords({ data: { projectId } }), + const filters = useSavedKeywordsFilters(); + const [committedFilterValues, setCommittedFilterValues] = useState( + filters.values, + ); + + useEffect(() => { + const timer = window.setTimeout(() => { + setCommittedFilterValues(filters.values); + setPage(1); + }, FILTER_DEBOUNCE_MS); + return () => window.clearTimeout(timer); + }, [filters.values]); + + const appliedFilters = useMemo( + () => compileSavedKeywordsFilters(committedFilterValues), + [committedFilterValues], + ); + const exportFilters = useMemo( + () => compileSavedKeywordsFilters(filters.values), + [filters.values], + ); + + const sortState = sorting[0]; + const sort = toSavedKeywordSort(sortState?.id); + const order: "asc" | "desc" = sortState + ? sortState.desc + ? "desc" + : "asc" + : "desc"; + const tagFilterKey = selectedTagIds.join("|"); + const hasActiveFilters = + filters.activeFilterCount > 0 || selectedTagIds.length > 0; + + const queryInput = useMemo( + () => ({ + projectId, + ...appliedFilters, + tagIds: selectedTagIds.length > 0 ? selectedTagIds : undefined, + page, + pageSize, + sort, + order, + }), + [appliedFilters, order, page, pageSize, projectId, selectedTagIds, sort], + ); + + const { data, isLoading, isFetching } = useQuery({ + queryKey: ["savedKeywords", projectId, queryInput], + queryFn: () => getSavedKeywords({ data: queryInput }), + placeholderData: keepPreviousData, }); - const savedKeywords: SavedKeyword[] = savedKeywordsData?.rows ?? []; + + const savedKeywords = data?.rows ?? []; + const availableTags = data?.tags ?? []; + const totalCount = data?.totalCount ?? 0; + const totalPages = Math.max(1, Math.ceil(totalCount / pageSize)); + const selectedRows = savedKeywords.filter((row) => rowSelection[row.id]); + const selectedIds = selectedRows.map((row) => row.id); + const selectedCount = selectedIds.length; + + const selectedRowTags = useMemo(() => { + const map = new Map(); + for (const row of selectedRows) { + for (const tag of row.tags) { + if (!map.has(tag.id)) map.set(tag.id, tag); + } + } + return [...map.values()].toSorted((a, b) => + a.normalizedName.localeCompare(b.normalizedName), + ); + }, [selectedRows]); + + useEffect(() => { + setRowSelection({}); + }, [page, pageSize, appliedFilters, tagFilterKey, sort, order]); + + useEffect(() => { + if (page > totalPages) setPage(totalPages); + }, [page, totalPages]); + + const invalidateSavedKeywords = () => + queryClient.invalidateQueries({ queryKey: ["savedKeywords", projectId] }); const removeMutation = useMutation({ mutationFn: (savedKeywordIds: string[]) => removeSavedKeywords({ data: { projectId, savedKeywordIds } }), - }); - - const handleDeleteSelected = async () => { - const ids = [...selected]; - if (ids.length === 0) return; - - setDeleting(true); - setRemoveError(null); - - try { - await removeMutation.mutateAsync(ids); - setSelected(new Set()); + onSuccess: (result) => { + setRowSelection({}); setShowConfirm(false); + setRemoveError(null); + void invalidateSavedKeywords(); captureClientEvent("saved_keywords:bulk_remove", { - count: ids.length, + count: result.deletedCount, }); toast.success( - `${ids.length} keyword${ids.length !== 1 ? "s" : ""} removed`, + `${result.deletedCount} keyword${result.deletedCount !== 1 ? "s" : ""} removed`, ); - } catch (error) { + }, + onError: (error) => { setRemoveError(getStandardErrorMessage(error, "Remove failed.")); - } finally { - void queryClient.invalidateQueries({ - queryKey: ["savedKeywords", projectId], - }); - setDeleting(false); - } - }; + }, + }); - const handleCopySelected = () => { - const keywords = savedKeywords - .filter((kw) => selected.has(kw.id)) - .map((kw) => kw.keyword); - void navigator.clipboard.writeText(keywords.join("\n")); - toast.success( - `${keywords.length} keyword${keywords.length !== 1 ? "s" : ""} copied`, + const tagMutation = useMutation({ + mutationFn: (input: { + savedKeywordIds: string[]; + addTags?: string[]; + removeTagIds?: string[]; + }) => + updateSavedKeywordTags({ + data: { + projectId, + savedKeywordIds: input.savedKeywordIds, + addTags: input.addTags, + removeTagIds: input.removeTagIds, + }, + }), + onSuccess: (result) => { + setRowSelection({}); + setShowTagModal(false); + void invalidateSavedKeywords(); + toast.success( + `Updated tags for ${result.taggedCount} keyword${result.taggedCount !== 1 ? "s" : ""}`, + ); + }, + onError: (error) => { + toast.error(getStandardErrorMessage(error, "Could not update tags")); + }, + }); + + const tagManage = useTagManage(projectId); + const exporter = useSavedKeywordsExport({ + projectId, + appliedFilters: exportFilters, + selectedTagIds, + sort, + order, + }); + + const handleSortingChange: OnChangeFn = (updater) => { + setSorting((current) => + typeof updater === "function" ? updater(current) : updater, ); + setPage(1); }; - const toggleSelect = (id: string) => { - setSelected((prev) => { - const next = new Set(prev); - if (next.has(id)) next.delete(id); - else next.add(id); - return next; - }); - }; - - const toggleAll = () => { - if (selected.size === savedKeywords.length) { - setSelected(new Set()); - } else { - setSelected(new Set(savedKeywords.map((kw) => kw.id))); + const handleDeleteTag = async (tagId: string) => { + const ok = await tagManage.deleteTag(tagId); + if (ok) { + setSelectedTagIds((current) => current.filter((id) => id !== tagId)); } }; - const savedHeaders = [...KEYWORD_RESEARCH_HEADERS, "Fetched At"]; - const sheetsExportRows: CsvValue[][] = savedKeywords.map((kw) => [ - kw.keyword, - kw.searchVolume ?? "", - kw.cpc ?? "", - kw.competition ?? "", - kw.keywordDifficulty ?? "", - kw.intent ?? "", - kw.fetchedAt ?? "", - ]); - - const exportCsv = () => { - if (sheetsExportRows.length === 0) { - toast.error("No keywords to export"); - return; - } - // CSV file keeps cents-formatted CPC/competition for human readability. - const csvRows = sheetsExportRows.map((row) => - row.map((cell, idx) => - (idx === 2 || idx === 3) && typeof cell === "number" - ? cell.toFixed(2) - : cell, - ), - ); - downloadCsv("saved-keywords.csv", buildCsv(savedHeaders, csvRows)); - captureClientEvent("data:export", { - source_feature: "saved_keywords", - result_count: sheetsExportRows.length, - }); + const handleClearAllFilters = () => { + filters.resetFilters(); + setSelectedTagIds([]); + setPage(1); }; return ( -
-
-
-
-

Saved Keywords

-

- Keywords you've saved from keyword research. -

+
+
+ void exporter.exportFilteredCsv()} + onExportSheets={() => void exporter.exportFilteredSheets()} + /> + +
+ setShowFilters((v) => !v)} + onResetAllFilters={handleClearAllFilters} + availableTags={availableTags} + selectedTagIds={selectedTagIds} + busyTagIds={tagManage.busyTagIds} + onToggleTagFilter={(tagId) => { + setSelectedTagIds((current) => + current.includes(tagId) + ? current.filter((id) => id !== tagId) + : [...current, tagId], + ); + setPage(1); + }} + onClearTagSelection={() => { + setSelectedTagIds([]); + setPage(1); + }} + onUpdateTag={(input) => void tagManage.updateTag(input)} + onDeleteTag={(tagId) => void handleDeleteTag(tagId)} + /> + +
+ {removeError ? ( + + ) : null} + +
- {savedKeywords.length > 0 && ( -
- - -
- )} + + { + setPageSize(nextPageSize); + setPage(1); + }} + />
- {isLoading ? ( -
-
-
- {Array.from({ length: 8 }).map((_, index) => ( -
-
-
-
-
-
-
-
-
- ))} -
-
- ) : savedKeywords.length === 0 ? ( -
-
- -

- No saved keywords yet. Use the Keyword Research page to find and - save keywords. -

-
-
- ) : ( -
-
- {removeError ? ( -
- - {removeError} -
- ) : null} + { + void navigator.clipboard.writeText( + selectedRows.map((row) => row.keyword).join("\n"), + ); + toast.success( + `${selectedCount} keyword${selectedCount !== 1 ? "s" : ""} copied`, + ); + }} + onOpenTags={() => setShowTagModal(true)} + onExportCsv={() => exporter.exportSelectionCsv(selectedRows)} + onExportSheets={() => + void exporter.exportSelectionSheets(selectedRows) + } + onDelete={() => setShowConfirm(true)} + onClear={() => setRowSelection({})} + /> - {/* Bulk action bar or keyword count */} - {selected.size > 0 ? ( -
- - {selected.size} keyword - {selected.size !== 1 ? "s" : ""} selected - - - - -
- ) : ( -

- {savedKeywords.length} saved keyword - {savedKeywords.length !== 1 ? "s" : ""} -

- )} + {showConfirm ? ( + setShowConfirm(false)} + onConfirm={() => removeMutation.mutate(selectedIds)} + /> + ) : null} -
- - - - - - - - - - - - - - - {savedKeywords.map((kw) => ( - - - - - - - - - - - ))} - -
- 0 - } - onChange={toggleAll} - /> - KeywordVolumeCPCCompetitionDifficultyIntentLast Fetched
- toggleSelect(kw.id)} - /> - {kw.keyword}{formatNumber(kw.searchVolume)} - {kw.cpc == null ? "-" : `$${kw.cpc.toFixed(2)}`} - - {kw.competition == null - ? "-" - : kw.competition.toFixed(2)} - - - - - {kw.intent ?? "?"} - - - {kw.fetchedAt - ? new Date(kw.fetchedAt).toLocaleDateString() - : "-"} -
-
-
-
- )} - - {/* Confirm delete modal */} - {showConfirm && ( -
-
-
-

Delete keywords?

-

- This will permanently delete {selected.size} saved keyword - {selected.size !== 1 ? "s" : ""}. -

-
- - -
-
-
-
- )} + {showTagModal ? ( + setShowTagModal(false)} + onApply={({ addTags, removeTagIds }) => + tagMutation.mutate({ + savedKeywordIds: selectedIds, + addTags, + removeTagIds, + }) + } + /> + ) : null}
); } - -function DifficultyBadge({ value }: { value: number | null }) { - if (value == null) - return -; - if (value < 30) - return {value}; - if (value <= 60) - return {value}; - return {value}; -} - -function formatNumber(value: number | null | undefined) { - if (value == null) return "-"; - return new Intl.NumberFormat().format(value); -} diff --git a/src/server/features/keywords/repositories/KeywordResearchRepository.ts b/src/server/features/keywords/repositories/KeywordResearchRepository.ts index 97fcdfb..e769000 100644 --- a/src/server/features/keywords/repositories/KeywordResearchRepository.ts +++ b/src/server/features/keywords/repositories/KeywordResearchRepository.ts @@ -1,6 +1,61 @@ -import { and, count, desc, eq, inArray } from "drizzle-orm"; +import { + and, + asc, + count, + desc, + eq, + gte, + inArray, + lte, + sql, + type SQL, +} from "drizzle-orm"; import { db } from "@/db"; -import { keywordMetrics, savedKeywords } from "@/db/schema"; +import { + keywordMetrics, + savedKeywordTagAssignments, + savedKeywords, +} from "@/db/schema"; +import { + SavedKeywordTagsRepository, + type SavedKeywordTagRecord, +} from "./SavedKeywordTagsRepository"; + +type SavedKeywordRecord = typeof savedKeywords.$inferSelect; +type KeywordMetricRecord = typeof keywordMetrics.$inferSelect; +type SavedKeywordsListParams = { + projectId: string; + search?: string; + includeTerms?: string[]; + excludeTerms?: string[]; + minVolume?: number | null; + maxVolume?: number | null; + minCpc?: number | null; + maxCpc?: number | null; + minDifficulty?: number | null; + maxDifficulty?: number | null; + tagIds?: string[]; + tagNames?: string[]; + page?: number; + pageSize?: number; + sort?: SavedKeywordSortField; + order?: "asc" | "desc"; +}; + +type SavedKeywordSortField = + | "createdAt" + | "keyword" + | "searchVolume" + | "cpc" + | "competition" + | "keywordDifficulty" + | "fetchedAt"; + +type SavedKeywordListRow = { + row: SavedKeywordRecord; + metric: KeywordMetricRecord | null; + tags: SavedKeywordTagRecord[]; +}; async function upsertKeywordMetric(params: { projectId: string; @@ -63,8 +118,8 @@ async function saveKeywordsToProject(params: { keywords: string[]; locationCode: number; languageCode: string; -}) { - if (params.keywords.length === 0) return; +}): Promise { + if (params.keywords.length === 0) return []; const [first, ...rest] = params.keywords.map((keyword) => db @@ -80,27 +135,208 @@ async function saveKeywordsToProject(params: { ); await db.batch([first, ...rest]); + + return listSavedKeywordRowsByKeywords(params); } -async function listSavedKeywordsByProject(projectId: string) { - return db +function escapeLike(value: string) { + return value.replace(/[\\%_]/g, (char) => `\\${char}`); +} + +function buildSavedKeywordWhere(params: { + projectId: string; + search?: string; + includeTerms?: string[]; + excludeTerms?: string[]; + minVolume?: number | null; + maxVolume?: number | null; + minCpc?: number | null; + maxCpc?: number | null; + minDifficulty?: number | null; + maxDifficulty?: number | null; + tagIds?: string[]; +}) { + const clauses: SQL[] = [eq(savedKeywords.projectId, params.projectId)]; + const search = params.search?.trim(); + if (search) { + clauses.push( + sql`lower(${savedKeywords.keyword}) like ${`%${escapeLike(search.toLocaleLowerCase())}%`} escape '\\'`, + ); + } + for (const term of params.includeTerms ?? []) { + const trimmed = term.trim(); + if (!trimmed) continue; + clauses.push( + sql`lower(${savedKeywords.keyword}) like ${`%${escapeLike(trimmed.toLocaleLowerCase())}%`} escape '\\'`, + ); + } + for (const term of params.excludeTerms ?? []) { + const trimmed = term.trim(); + if (!trimmed) continue; + clauses.push( + sql`lower(${savedKeywords.keyword}) not like ${`%${escapeLike(trimmed.toLocaleLowerCase())}%`} escape '\\'`, + ); + } + if (params.minVolume != null) { + clauses.push(gte(keywordMetrics.searchVolume, params.minVolume)); + } + if (params.maxVolume != null) { + clauses.push(lte(keywordMetrics.searchVolume, params.maxVolume)); + } + if (params.minCpc != null) { + clauses.push(gte(keywordMetrics.cpc, params.minCpc)); + } + if (params.maxCpc != null) { + clauses.push(lte(keywordMetrics.cpc, params.maxCpc)); + } + if (params.minDifficulty != null) { + clauses.push(gte(keywordMetrics.keywordDifficulty, params.minDifficulty)); + } + if (params.maxDifficulty != null) { + clauses.push(lte(keywordMetrics.keywordDifficulty, params.maxDifficulty)); + } + if (params.tagIds && params.tagIds.length > 0) { + clauses.push( + sql`exists ( + select 1 + from ${savedKeywordTagAssignments} + where ${savedKeywordTagAssignments.savedKeywordId} = ${savedKeywords.id} + and ${inArray(savedKeywordTagAssignments.tagId, params.tagIds)} + )`, + ); + } + return and(...clauses); +} + +function buildSavedKeywordOrderBy( + sort: SavedKeywordSortField = "createdAt", + order: "asc" | "desc" = "desc", +) { + const direction = order === "asc" ? asc : desc; + switch (sort) { + case "keyword": + return direction(savedKeywords.keyword); + case "searchVolume": + return direction(keywordMetrics.searchVolume); + case "cpc": + return direction(keywordMetrics.cpc); + case "competition": + return direction(keywordMetrics.competition); + case "keywordDifficulty": + return direction(keywordMetrics.keywordDifficulty); + case "fetchedAt": + return direction(keywordMetrics.fetchedAt); + case "createdAt": + default: + return direction(savedKeywords.createdAt); + } +} + +async function listSavedKeywordsByProject( + params: SavedKeywordsListParams, +): Promise<{ + rows: SavedKeywordListRow[]; + totalCount: number; + tags: (SavedKeywordTagRecord & { keywordCount: number })[]; +}> { + const [{ tagIds, emptyTagNameMatch }, tags] = await Promise.all([ + SavedKeywordTagsRepository.getTagFilterIds(params), + SavedKeywordTagsRepository.listSavedKeywordTagsByProject(params.projectId), + ]); + + if (emptyTagNameMatch) { + return { rows: [], totalCount: 0, tags }; + } + + const where = buildSavedKeywordWhere({ + projectId: params.projectId, + search: params.search, + includeTerms: params.includeTerms, + excludeTerms: params.excludeTerms, + minVolume: params.minVolume, + maxVolume: params.maxVolume, + minCpc: params.minCpc, + maxCpc: params.maxCpc, + minDifficulty: params.minDifficulty, + maxDifficulty: params.maxDifficulty, + tagIds, + }); + + const metricJoin = and( + eq(keywordMetrics.keyword, savedKeywords.keyword), + eq(keywordMetrics.projectId, savedKeywords.projectId), + eq(keywordMetrics.locationCode, savedKeywords.locationCode), + eq(keywordMetrics.languageCode, savedKeywords.languageCode), + ); + + const [{ value: totalCount } = { value: 0 }] = await db + .select({ value: count() }) + .from(savedKeywords) + .leftJoin(keywordMetrics, metricJoin) + .where(where); + + const baseQuery = db .select({ row: savedKeywords, metric: keywordMetrics }) .from(savedKeywords) - .leftJoin( - keywordMetrics, - and( - eq(keywordMetrics.keyword, savedKeywords.keyword), - eq(keywordMetrics.projectId, savedKeywords.projectId), - eq(keywordMetrics.locationCode, savedKeywords.locationCode), - eq(keywordMetrics.languageCode, savedKeywords.languageCode), - ), - ) - .where(eq(savedKeywords.projectId, projectId)) - .orderBy(desc(savedKeywords.createdAt)); + .leftJoin(keywordMetrics, metricJoin) + .where(where) + .orderBy( + buildSavedKeywordOrderBy(params.sort, params.order), + asc(savedKeywords.id), + ); + + const rows = + params.pageSize == null + ? await baseQuery + : await baseQuery + .limit(params.pageSize) + .offset(((params.page ?? 1) - 1) * params.pageSize); + const tagsByKeywordId = + await SavedKeywordTagsRepository.listTagsBySavedKeywordIds( + params.projectId, + rows.map(({ row }) => row.id), + ); + + return { + totalCount, + tags, + rows: rows.map(({ row, metric }) => ({ + row, + metric, + tags: tagsByKeywordId.get(row.id) ?? [], + })), + }; +} + +async function listSavedKeywordRowsByKeywords(params: { + projectId: string; + keywords: string[]; + locationCode: number; + languageCode: string; +}) { + const rows: SavedKeywordRecord[] = []; + for (let i = 0; i < params.keywords.length; i += QUERY_CHUNK_SIZE) { + const chunk = params.keywords.slice(i, i + QUERY_CHUNK_SIZE); + rows.push( + ...(await db + .select() + .from(savedKeywords) + .where( + and( + eq(savedKeywords.projectId, params.projectId), + eq(savedKeywords.locationCode, params.locationCode), + eq(savedKeywords.languageCode, params.languageCode), + inArray(savedKeywords.keyword, chunk), + ), + )), + ); + } + return rows; } // D1 caps bound parameters at 100 per statement; leave headroom for the // projectId filter. +const QUERY_CHUNK_SIZE = 80; const DELETE_CHUNK_SIZE = 90; async function removeSavedKeywords( @@ -129,5 +365,14 @@ export const KeywordResearchRepository = { countSavedKeywords, saveKeywordsToProject, listSavedKeywordsByProject, + addTagsToSavedKeywords: SavedKeywordTagsRepository.addTagsToSavedKeywords, + replaceTagsForSavedKeywords: + SavedKeywordTagsRepository.replaceTagsForSavedKeywords, + removeTagsFromSavedKeywords: + SavedKeywordTagsRepository.removeTagsFromSavedKeywords, + removeAllTagsFromSavedKeywords: + SavedKeywordTagsRepository.removeAllTagsFromSavedKeywords, + updateSavedKeywordTag: SavedKeywordTagsRepository.updateSavedKeywordTag, + deleteSavedKeywordTag: SavedKeywordTagsRepository.deleteSavedKeywordTag, removeSavedKeywords, } as const; diff --git a/src/server/features/keywords/repositories/SavedKeywordTagsRepository.ts b/src/server/features/keywords/repositories/SavedKeywordTagsRepository.ts new file mode 100644 index 0000000..8e8455d --- /dev/null +++ b/src/server/features/keywords/repositories/SavedKeywordTagsRepository.ts @@ -0,0 +1,413 @@ +import { and, asc, count, eq, inArray, notInArray } from "drizzle-orm"; +import { db } from "@/db"; +import { + savedKeywordTagAssignments, + savedKeywordTags, + savedKeywords, +} from "@/db/schema"; +import { + normalizeSavedKeywordTag, + normalizeSavedKeywordTags, +} from "@/shared/saved-keyword-tags"; + +export type SavedKeywordTagRecord = typeof savedKeywordTags.$inferSelect; + +const QUERY_CHUNK_SIZE = 80; +const DELETE_PAIR_CHUNK_SIZE = 45; +const ASSIGNMENT_INSERT_CHUNK_SIZE = 40; +const REPLACE_DELETE_KEYWORD_CHUNK_SIZE = 70; + +async function getTagFilterIds(params: { + projectId: string; + tagIds?: string[]; + tagNames?: string[]; +}): Promise<{ tagIds: string[]; emptyTagNameMatch: boolean }> { + const directTagIds = params.tagIds ?? []; + const normalizedTags = normalizeSavedKeywordTags(params.tagNames); + if (normalizedTags.length === 0) { + return { tagIds: [...new Set(directTagIds)], emptyTagNameMatch: false }; + } + + const rows = await db + .select({ id: savedKeywordTags.id }) + .from(savedKeywordTags) + .where( + and( + eq(savedKeywordTags.projectId, params.projectId), + inArray( + savedKeywordTags.normalizedName, + normalizedTags.map((tag) => tag.normalizedName), + ), + ), + ); + + return { + tagIds: [...new Set([...directTagIds, ...rows.map((row) => row.id)])], + emptyTagNameMatch: directTagIds.length === 0 && rows.length === 0, + }; +} + +async function listSavedKeywordTagsByProject(projectId: string) { + return db + .select({ + id: savedKeywordTags.id, + projectId: savedKeywordTags.projectId, + name: savedKeywordTags.name, + normalizedName: savedKeywordTags.normalizedName, + color: savedKeywordTags.color, + createdAt: savedKeywordTags.createdAt, + keywordCount: count(savedKeywordTagAssignments.savedKeywordId), + }) + .from(savedKeywordTags) + .leftJoin( + savedKeywordTagAssignments, + eq(savedKeywordTagAssignments.tagId, savedKeywordTags.id), + ) + .where(eq(savedKeywordTags.projectId, projectId)) + .groupBy( + savedKeywordTags.id, + savedKeywordTags.projectId, + savedKeywordTags.name, + savedKeywordTags.normalizedName, + savedKeywordTags.color, + savedKeywordTags.createdAt, + ) + .orderBy(asc(savedKeywordTags.normalizedName)); +} + +async function listTagsBySavedKeywordIds( + projectId: string, + savedKeywordIds: string[], +) { + const tagsByKeywordId = new Map(); + if (savedKeywordIds.length === 0) return tagsByKeywordId; + + for (let i = 0; i < savedKeywordIds.length; i += QUERY_CHUNK_SIZE) { + const chunk = savedKeywordIds.slice(i, i + QUERY_CHUNK_SIZE); + const rows = await db + .select({ + savedKeywordId: savedKeywordTagAssignments.savedKeywordId, + tag: savedKeywordTags, + }) + .from(savedKeywordTagAssignments) + .innerJoin( + savedKeywordTags, + eq(savedKeywordTags.id, savedKeywordTagAssignments.tagId), + ) + .where( + and( + eq(savedKeywordTags.projectId, projectId), + inArray(savedKeywordTagAssignments.savedKeywordId, chunk), + ), + ) + .orderBy(asc(savedKeywordTags.normalizedName)); + + for (const { savedKeywordId, tag } of rows) { + const tags = tagsByKeywordId.get(savedKeywordId) ?? []; + tags.push(tag); + tagsByKeywordId.set(savedKeywordId, tags); + } + } + + return tagsByKeywordId; +} + +async function addTagsToSavedKeywords(params: { + projectId: string; + savedKeywordIds: string[]; + tagNames: string[]; +}) { + const savedKeywordRows = await listSavedKeywordRowsByIds( + params.projectId, + params.savedKeywordIds, + ); + if (savedKeywordRows.length === 0) { + return { savedKeywordCount: 0, tags: [] }; + } + + const tags = await upsertSavedKeywordTags(params.projectId, params.tagNames); + + const assignments = savedKeywordRows.flatMap((row) => + tags.map((tag) => ({ savedKeywordId: row.id, tagId: tag.id })), + ); + for (let i = 0; i < assignments.length; i += ASSIGNMENT_INSERT_CHUNK_SIZE) { + const chunk = assignments.slice(i, i + ASSIGNMENT_INSERT_CHUNK_SIZE); + await db + .insert(savedKeywordTagAssignments) + .values(chunk) + .onConflictDoNothing(); + } + + return { savedKeywordCount: savedKeywordRows.length, tags }; +} + +async function replaceTagsForSavedKeywords(params: { + projectId: string; + savedKeywordIds: string[]; + tagNames: string[]; +}) { + const addResult = await addTagsToSavedKeywords(params); + const keepTagIds = addResult.tags.map((tag) => tag.id); + if (addResult.savedKeywordCount === 0 || keepTagIds.length === 0) { + return { ...addResult, removedCount: 0 }; + } + + let removedCount = 0; + for ( + let i = 0; + i < params.savedKeywordIds.length; + i += REPLACE_DELETE_KEYWORD_CHUNK_SIZE + ) { + const chunk = params.savedKeywordIds.slice( + i, + i + REPLACE_DELETE_KEYWORD_CHUNK_SIZE, + ); + const savedKeywordRows = await listSavedKeywordRowsByIds( + params.projectId, + chunk, + ); + const savedKeywordIds = savedKeywordRows.map((row) => row.id); + if (savedKeywordIds.length === 0) continue; + + const deleted = await db + .delete(savedKeywordTagAssignments) + .where( + and( + inArray(savedKeywordTagAssignments.savedKeywordId, savedKeywordIds), + notInArray(savedKeywordTagAssignments.tagId, keepTagIds), + ), + ) + .returning({ id: savedKeywordTagAssignments.savedKeywordId }); + removedCount += deleted.length; + } + + return { ...addResult, removedCount }; +} + +async function removeTagsFromSavedKeywords(params: { + projectId: string; + savedKeywordIds: string[]; + tagIds: string[]; +}) { + const [savedKeywordRows, tags] = await Promise.all([ + listSavedKeywordRowsByIds(params.projectId, params.savedKeywordIds), + listSavedKeywordTagsByIds(params.projectId, params.tagIds), + ]); + const savedKeywordIds = savedKeywordRows.map((row) => row.id); + const tagIds = tags.map((tag) => tag.id); + let removedCount = 0; + + for (let i = 0; i < savedKeywordIds.length; i += DELETE_PAIR_CHUNK_SIZE) { + const savedKeywordChunk = savedKeywordIds.slice( + i, + i + DELETE_PAIR_CHUNK_SIZE, + ); + for (let j = 0; j < tagIds.length; j += DELETE_PAIR_CHUNK_SIZE) { + const tagChunk = tagIds.slice(j, j + DELETE_PAIR_CHUNK_SIZE); + const deleted = await db + .delete(savedKeywordTagAssignments) + .where( + and( + inArray( + savedKeywordTagAssignments.savedKeywordId, + savedKeywordChunk, + ), + inArray(savedKeywordTagAssignments.tagId, tagChunk), + ), + ) + .returning({ tagId: savedKeywordTagAssignments.tagId }); + removedCount += deleted.length; + } + } + + return { removedCount, savedKeywordCount: savedKeywordRows.length, tags }; +} + +async function removeAllTagsFromSavedKeywords(params: { + projectId: string; + savedKeywordIds: string[]; +}) { + const savedKeywordRows = await listSavedKeywordRowsByIds( + params.projectId, + params.savedKeywordIds, + ); + let removedCount = 0; + + for (let i = 0; i < savedKeywordRows.length; i += QUERY_CHUNK_SIZE) { + const chunk = savedKeywordRows.slice(i, i + QUERY_CHUNK_SIZE); + const deleted = await db + .delete(savedKeywordTagAssignments) + .where( + inArray( + savedKeywordTagAssignments.savedKeywordId, + chunk.map((row) => row.id), + ), + ) + .returning({ id: savedKeywordTagAssignments.savedKeywordId }); + removedCount += deleted.length; + } + + return { removedCount, savedKeywordCount: savedKeywordRows.length }; +} + +async function upsertSavedKeywordTags( + projectId: string, + tagNames: readonly string[] | undefined, +) { + const normalizedTags = normalizeSavedKeywordTags(tagNames); + if (normalizedTags.length === 0) return []; + + const [first, ...rest] = normalizedTags.map((tag) => + db + .insert(savedKeywordTags) + .values({ + id: crypto.randomUUID(), + projectId, + name: tag.name, + normalizedName: tag.normalizedName, + }) + .onConflictDoNothing(), + ); + await db.batch([first, ...rest]); + + return db + .select() + .from(savedKeywordTags) + .where( + and( + eq(savedKeywordTags.projectId, projectId), + inArray( + savedKeywordTags.normalizedName, + normalizedTags.map((tag) => tag.normalizedName), + ), + ), + ) + .orderBy(asc(savedKeywordTags.normalizedName)); +} + +async function listSavedKeywordRowsByIds( + projectId: string, + savedKeywordIds: string[], +) { + const rows: (typeof savedKeywords.$inferSelect)[] = []; + for (let i = 0; i < savedKeywordIds.length; i += QUERY_CHUNK_SIZE) { + const chunk = savedKeywordIds.slice(i, i + QUERY_CHUNK_SIZE); + rows.push( + ...(await db + .select() + .from(savedKeywords) + .where( + and( + eq(savedKeywords.projectId, projectId), + inArray(savedKeywords.id, chunk), + ), + )), + ); + } + return rows; +} + +async function listSavedKeywordTagsByIds(projectId: string, tagIds: string[]) { + if (tagIds.length === 0) return []; + const rows: SavedKeywordTagRecord[] = []; + for (let i = 0; i < tagIds.length; i += QUERY_CHUNK_SIZE) { + const chunk = tagIds.slice(i, i + QUERY_CHUNK_SIZE); + rows.push( + ...(await db + .select() + .from(savedKeywordTags) + .where( + and( + eq(savedKeywordTags.projectId, projectId), + inArray(savedKeywordTags.id, chunk), + ), + )), + ); + } + return rows; +} + +async function updateSavedKeywordTag(params: { + projectId: string; + tagId: string; + name?: string; + color?: string | null; +}) { + const updates: Partial = {}; + if (params.name !== undefined) { + const normalizedTag = normalizeSavedKeywordTag(params.name); + if (!normalizedTag) return null; + updates.name = normalizedTag.name; + updates.normalizedName = normalizedTag.normalizedName; + } + if (params.color !== undefined) { + updates.color = params.color; + } + if (Object.keys(updates).length === 0) return null; + + const [updated] = await db + .update(savedKeywordTags) + .set(updates) + .where( + and( + eq(savedKeywordTags.projectId, params.projectId), + eq(savedKeywordTags.id, params.tagId), + ), + ) + .returning(); + return updated ?? null; +} + +async function deleteSavedKeywordTag(params: { + projectId: string; + tagId: string; +}): Promise< + | { status: "deleted" } + | { status: "not_found" } + | { status: "in_use"; assignmentCount: number } +> { + const [tag] = await db + .select({ id: savedKeywordTags.id }) + .from(savedKeywordTags) + .where( + and( + eq(savedKeywordTags.projectId, params.projectId), + eq(savedKeywordTags.id, params.tagId), + ), + ); + if (!tag) return { status: "not_found" }; + + // Guard: refuse to delete a tag that's still attached to saved keywords. + // FK cascade would otherwise silently drop assignments. + const [{ value: assignmentCount } = { value: 0 }] = await db + .select({ value: count() }) + .from(savedKeywordTagAssignments) + .where(eq(savedKeywordTagAssignments.tagId, params.tagId)); + + if (assignmentCount > 0) { + return { status: "in_use", assignmentCount }; + } + + const deleted = await db + .delete(savedKeywordTags) + .where( + and( + eq(savedKeywordTags.projectId, params.projectId), + eq(savedKeywordTags.id, params.tagId), + ), + ) + .returning({ id: savedKeywordTags.id }); + return deleted.length > 0 ? { status: "deleted" } : { status: "not_found" }; +} + +export const SavedKeywordTagsRepository = { + getTagFilterIds, + listSavedKeywordTagsByProject, + listTagsBySavedKeywordIds, + addTagsToSavedKeywords, + replaceTagsForSavedKeywords, + removeTagsFromSavedKeywords, + removeAllTagsFromSavedKeywords, + updateSavedKeywordTag, + deleteSavedKeywordTag, +} as const; diff --git a/src/server/features/keywords/services/KeywordResearchService.ts b/src/server/features/keywords/services/KeywordResearchService.ts index a596cba..98c7488 100644 --- a/src/server/features/keywords/services/KeywordResearchService.ts +++ b/src/server/features/keywords/services/KeywordResearchService.ts @@ -1,9 +1,13 @@ import { + deleteSavedKeywordTag, getSavedKeywords, getSerpAnalysis, removeSavedKeywords, research, saveKeywords, + exportSavedKeywords, + updateSavedKeywordTag, + updateSavedKeywordTags, } from "@/server/features/keywords/services/research"; export const KeywordResearchService = { @@ -11,5 +15,9 @@ export const KeywordResearchService = { getSerpAnalysis, saveKeywords, getSavedKeywords, + exportSavedKeywords, + updateSavedKeywordTags, + updateSavedKeywordTag, + deleteSavedKeywordTag, removeSavedKeywords, } as const; diff --git a/src/server/features/keywords/services/research/index.ts b/src/server/features/keywords/services/research/index.ts index ef0fb78..8eb7e20 100644 --- a/src/server/features/keywords/services/research/index.ts +++ b/src/server/features/keywords/services/research/index.ts @@ -3,5 +3,9 @@ export { getSerpAnalysis } from "./serp"; export { saveKeywords, getSavedKeywords, + exportSavedKeywords, + updateSavedKeywordTags, + updateSavedKeywordTag, + deleteSavedKeywordTag, removeSavedKeywords, } from "./saved-keywords"; diff --git a/src/server/features/keywords/services/research/saved-keywords.test.ts b/src/server/features/keywords/services/research/saved-keywords.test.ts new file mode 100644 index 0000000..e323a92 --- /dev/null +++ b/src/server/features/keywords/services/research/saved-keywords.test.ts @@ -0,0 +1,248 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + addTagsToSavedKeywords: vi.fn(), + listSavedKeywordsByProject: vi.fn(), + removeAllTagsFromSavedKeywords: vi.fn(), + removeSavedKeywords: vi.fn(), + removeTagsFromSavedKeywords: vi.fn(), + replaceTagsForSavedKeywords: vi.fn(), + saveKeywordsToProject: vi.fn(), + upsertKeywordMetric: vi.fn(), +})); + +vi.mock( + "@/server/features/keywords/repositories/KeywordResearchRepository", + () => ({ + KeywordResearchRepository: mocks, + }), +); + +const savedKeywordRow = { + id: "saved_1", + projectId: "project_1", + keyword: "technical seo", + locationCode: 2840, + languageCode: "en", + createdAt: "2026-05-11T00:00:00.000Z", +}; + +describe("saved keyword service", () => { + beforeEach(() => { + vi.resetModules(); + for (const mock of Object.values(mocks)) mock.mockReset(); + }); + + it("attaches tags to saved keyword rows after saving", async () => { + mocks.saveKeywordsToProject.mockResolvedValue([ + savedKeywordRow, + { ...savedKeywordRow, id: "saved_2", keyword: "content seo" }, + ]); + mocks.addTagsToSavedKeywords.mockResolvedValue({ + savedKeywordCount: 2, + tags: [], + }); + const { saveKeywords } = await import("./saved-keywords"); + + await saveKeywords({ + projectId: "project_1", + keywords: [" Technical SEO ", "technical seo", "Content SEO"], + locationCode: 2840, + languageCode: "en", + tagMode: "append", + tags: ["Content", "BOFU"], + }); + + expect(mocks.saveKeywordsToProject).toHaveBeenCalledWith({ + projectId: "project_1", + keywords: ["technical seo", "content seo"], + locationCode: 2840, + languageCode: "en", + }); + expect(mocks.addTagsToSavedKeywords).toHaveBeenCalledWith({ + projectId: "project_1", + savedKeywordIds: ["saved_1", "saved_2"], + tagNames: ["Content", "BOFU"], + }); + }); + + it("does not call tag assignment when no tags are provided", async () => { + mocks.saveKeywordsToProject.mockResolvedValue([savedKeywordRow]); + const { saveKeywords } = await import("./saved-keywords"); + + await saveKeywords({ + projectId: "project_1", + keywords: ["technical seo"], + locationCode: 2840, + languageCode: "en", + tagMode: "append", + }); + + expect(mocks.addTagsToSavedKeywords).not.toHaveBeenCalled(); + }); + + it("maps paged saved keyword rows with attached tags", async () => { + mocks.listSavedKeywordsByProject.mockResolvedValue({ + totalCount: 1, + tags: [ + { + id: "tag_1", + projectId: "project_1", + name: "Content", + normalizedName: "content", + color: null, + createdAt: "2026-05-11T00:00:00.000Z", + keywordCount: 1, + }, + ], + rows: [ + { + row: savedKeywordRow, + metric: { + id: 1, + projectId: "project_1", + keyword: "technical seo", + locationCode: 2840, + languageCode: "en", + searchVolume: 120, + cpc: 2.5, + competition: 0.2, + keywordDifficulty: 18, + intent: "informational", + monthlySearches: null, + fetchedAt: "2026-05-10T00:00:00.000Z", + }, + tags: [ + { + id: "tag_1", + projectId: "project_1", + name: "Content", + normalizedName: "content", + color: null, + createdAt: "2026-05-11T00:00:00.000Z", + }, + ], + }, + ], + }); + const { getSavedKeywords } = await import("./saved-keywords"); + + const result = await getSavedKeywords({ + projectId: "project_1", + tagIds: ["tag_1"], + page: 1, + pageSize: 50, + sort: "createdAt", + order: "desc", + }); + + expect(mocks.listSavedKeywordsByProject).toHaveBeenCalledWith({ + projectId: "project_1", + search: undefined, + tagIds: ["tag_1"], + tagNames: undefined, + page: 1, + pageSize: 50, + sort: "createdAt", + order: "desc", + }); + expect(result.rows[0]?.tags).toEqual([ + { id: "tag_1", name: "Content", normalizedName: "content", color: null }, + ]); + expect(result.tags).toEqual([ + { + id: "tag_1", + name: "Content", + normalizedName: "content", + color: null, + keywordCount: 1, + }, + ]); + }); + + it("updates saved keyword tags through add and remove operations", async () => { + mocks.addTagsToSavedKeywords.mockResolvedValue({ + savedKeywordCount: 2, + tags: [ + { + id: "tag_1", + name: "Content", + normalizedName: "content", + }, + ], + }); + mocks.removeTagsFromSavedKeywords.mockResolvedValue({ + savedKeywordCount: 2, + removedCount: 2, + tags: [{ id: "tag_2" }], + }); + const { updateSavedKeywordTags } = await import("./saved-keywords"); + + const result = await updateSavedKeywordTags({ + projectId: "project_1", + savedKeywordIds: ["saved_1", "saved_2"], + addTags: ["Content"], + removeTagIds: ["tag_2"], + }); + + expect(result).toMatchObject({ + success: true, + taggedCount: 2, + addedTags: [ + { + id: "tag_1", + name: "Content", + normalizedName: "content", + color: null, + }, + ], + removedTagIds: ["tag_2"], + removedAssignments: 2, + }); + }); + + it("replaces tags only for the exact saved keyword rows returned by save", async () => { + mocks.saveKeywordsToProject.mockResolvedValue([ + { ...savedKeywordRow, id: "saved_us", keyword: "technical seo" }, + ]); + mocks.replaceTagsForSavedKeywords.mockResolvedValue({ + savedKeywordCount: 1, + removedCount: 1, + tags: [{ id: "tag_new", name: "US", normalizedName: "us" }], + }); + const { saveKeywords } = await import("./saved-keywords"); + + const result = await saveKeywords({ + projectId: "project_1", + keywords: ["technical seo"], + locationCode: 2840, + languageCode: "en", + tags: ["US"], + tagMode: "replace", + }); + + expect(mocks.replaceTagsForSavedKeywords).toHaveBeenCalledWith({ + projectId: "project_1", + savedKeywordIds: ["saved_us"], + tagNames: ["US"], + }); + expect(mocks.addTagsToSavedKeywords).not.toHaveBeenCalled(); + expect(result.savedKeywordIds).toEqual(["saved_us"]); + }); + + it("rejects replace mode without replacement tags", async () => { + mocks.saveKeywordsToProject.mockResolvedValue([savedKeywordRow]); + const { saveKeywords } = await import("./saved-keywords"); + + await expect( + saveKeywords({ + projectId: "project_1", + keywords: ["technical seo"], + locationCode: 2840, + languageCode: "en", + tagMode: "replace", + }), + ).rejects.toThrow("Replacement tags are required"); + expect(mocks.replaceTagsForSavedKeywords).not.toHaveBeenCalled(); + }); +}); diff --git a/src/server/features/keywords/services/research/saved-keywords.ts b/src/server/features/keywords/services/research/saved-keywords.ts index 9de812f..e5ecc67 100644 --- a/src/server/features/keywords/services/research/saved-keywords.ts +++ b/src/server/features/keywords/services/research/saved-keywords.ts @@ -1,11 +1,19 @@ import { KeywordResearchRepository } from "@/server/features/keywords/repositories/KeywordResearchRepository"; import { jsonCodec } from "@/shared/json"; import type { + DeleteSavedKeywordTagInput, + ExportSavedKeywordsInput, GetSavedKeywordsInput, RemoveSavedKeywordsInput, SaveKeywordsInput, + UpdateSavedKeywordTagInput, + UpdateSavedKeywordTagsInput, } from "@/types/schemas/keywords"; -import type { MonthlySearch, SavedKeywordRow } from "@/types/keywords"; +import type { + MonthlySearch, + SavedKeywordRow, + SavedKeywordTagSummary, +} from "@/types/keywords"; import { normalizeKeyword } from "./helpers"; import { z } from "zod"; @@ -69,42 +77,212 @@ export async function saveKeywords(input: SaveKeywordsInput) { ); } - await KeywordResearchRepository.saveKeywordsToProject({ + const savedRows = await KeywordResearchRepository.saveKeywordsToProject({ projectId: input.projectId, keywords: normalizedKeywords, locationCode: input.locationCode, languageCode: input.languageCode, }); + const savedKeywordIds = savedRows.map((row) => row.id); - return { success: true }; -} - -export async function getSavedKeywords( - input: GetSavedKeywordsInput, -): Promise<{ rows: SavedKeywordRow[] }> { - const rows = await KeywordResearchRepository.listSavedKeywordsByProject( - input.projectId, - ); + if (input.tagMode === "replace") { + const replacementTags = input.tags ?? []; + if (replacementTags.length === 0) { + throw new Error("Replacement tags are required when tagMode is replace."); + } + await KeywordResearchRepository.replaceTagsForSavedKeywords({ + projectId: input.projectId, + savedKeywordIds, + tagNames: replacementTags, + }); + } else if ((input.tags?.length ?? 0) > 0) { + await KeywordResearchRepository.addTagsToSavedKeywords({ + projectId: input.projectId, + savedKeywordIds, + tagNames: input.tags ?? [], + }); + } return { - rows: rows.map(({ row, metric }) => ({ - id: row.id, - projectId: row.projectId, - keyword: row.keyword, - locationCode: row.locationCode, - languageCode: row.languageCode, - createdAt: row.createdAt, - searchVolume: metric?.searchVolume ?? null, - cpc: metric?.cpc ?? null, - competition: metric?.competition ?? null, - keywordDifficulty: metric?.keywordDifficulty ?? null, - intent: metric?.intent ?? null, - monthlySearches: parseMonthlySearches(metric?.monthlySearches ?? null), - fetchedAt: metric?.fetchedAt ?? null, - })), + success: true, + savedKeywordIds, }; } +export async function getSavedKeywords(input: GetSavedKeywordsInput): Promise<{ + rows: SavedKeywordRow[]; + totalCount: number; + tags: SavedKeywordTagSummary[]; +}> { + const result = await KeywordResearchRepository.listSavedKeywordsByProject({ + projectId: input.projectId, + search: input.search, + includeTerms: input.includeTerms, + excludeTerms: input.excludeTerms, + minVolume: input.minVolume, + maxVolume: input.maxVolume, + minCpc: input.minCpc, + maxCpc: input.maxCpc, + minDifficulty: input.minDifficulty, + maxDifficulty: input.maxDifficulty, + tagIds: input.tagIds, + tagNames: input.tagNames, + page: input.page, + pageSize: input.pageSize, + sort: input.sort, + order: input.order, + }); + + return { + rows: mapSavedKeywordRows(result.rows), + totalCount: result.totalCount, + tags: mapSavedKeywordTags(result.tags), + }; +} + +export async function exportSavedKeywords( + input: ExportSavedKeywordsInput, +): Promise<{ rows: SavedKeywordRow[] }> { + const result = await KeywordResearchRepository.listSavedKeywordsByProject({ + projectId: input.projectId, + search: input.search, + includeTerms: input.includeTerms, + excludeTerms: input.excludeTerms, + minVolume: input.minVolume, + maxVolume: input.maxVolume, + minCpc: input.minCpc, + maxCpc: input.maxCpc, + minDifficulty: input.minDifficulty, + maxDifficulty: input.maxDifficulty, + tagIds: input.tagIds, + tagNames: input.tagNames, + sort: input.sort, + order: input.order, + }); + + return { rows: mapSavedKeywordRows(result.rows) }; +} + +export async function updateSavedKeywordTags( + input: UpdateSavedKeywordTagsInput, +) { + const addResult = + (input.addTags?.length ?? 0) > 0 + ? await KeywordResearchRepository.addTagsToSavedKeywords({ + projectId: input.projectId, + savedKeywordIds: input.savedKeywordIds, + tagNames: input.addTags ?? [], + }) + : { savedKeywordCount: 0, tags: [] }; + + const removeResult = + (input.removeTagIds?.length ?? 0) > 0 + ? await KeywordResearchRepository.removeTagsFromSavedKeywords({ + projectId: input.projectId, + savedKeywordIds: input.savedKeywordIds, + tagIds: input.removeTagIds ?? [], + }) + : { removedCount: 0, savedKeywordCount: 0, tags: [] }; + + return { + success: true, + taggedCount: Math.max( + addResult.savedKeywordCount, + removeResult.savedKeywordCount, + ), + addedTags: addResult.tags.map((tag) => ({ + id: tag.id, + name: tag.name, + normalizedName: tag.normalizedName, + color: tag.color ?? null, + })), + removedTagIds: removeResult.tags.map((tag) => tag.id), + removedAssignments: removeResult.removedCount, + }; +} + +function mapSavedKeywordRows( + rows: Awaited< + ReturnType + >["rows"], +): SavedKeywordRow[] { + return rows.map(({ row, metric, tags }) => ({ + id: row.id, + projectId: row.projectId, + keyword: row.keyword, + locationCode: row.locationCode, + languageCode: row.languageCode, + createdAt: row.createdAt, + searchVolume: metric?.searchVolume ?? null, + cpc: metric?.cpc ?? null, + competition: metric?.competition ?? null, + keywordDifficulty: metric?.keywordDifficulty ?? null, + intent: metric?.intent ?? null, + monthlySearches: parseMonthlySearches(metric?.monthlySearches ?? null), + fetchedAt: metric?.fetchedAt ?? null, + tags: tags.map((tag) => ({ + id: tag.id, + name: tag.name, + normalizedName: tag.normalizedName, + color: tag.color ?? null, + })), + })); +} + +function mapSavedKeywordTags( + tags: Awaited< + ReturnType + >["tags"], +): SavedKeywordTagSummary[] { + return tags.map((tag) => ({ + id: tag.id, + name: tag.name, + normalizedName: tag.normalizedName, + color: tag.color ?? null, + keywordCount: tag.keywordCount, + })); +} + +export async function updateSavedKeywordTag(input: UpdateSavedKeywordTagInput) { + const updated = await KeywordResearchRepository.updateSavedKeywordTag({ + projectId: input.projectId, + tagId: input.tagId, + name: input.name, + color: input.color, + }); + if (!updated) return { success: false as const }; + return { + success: true as const, + tag: { + id: updated.id, + name: updated.name, + normalizedName: updated.normalizedName, + color: updated.color ?? null, + }, + }; +} + +export async function deleteSavedKeywordTag(input: DeleteSavedKeywordTagInput) { + const result = await KeywordResearchRepository.deleteSavedKeywordTag({ + projectId: input.projectId, + tagId: input.tagId, + }); + if (result.status === "in_use") { + throw new TagInUseError(result.assignmentCount); + } + return { success: result.status === "deleted" }; +} + +class TagInUseError extends Error { + readonly code = "TAG_IN_USE" as const; + constructor(readonly assignmentCount: number) { + super( + `Tag is attached to ${assignmentCount} keyword${assignmentCount === 1 ? "" : "s"}. Remove the tag from those keywords first.`, + ); + this.name = "TagInUseError"; + } +} + export async function removeSavedKeywords( projectId: string, input: RemoveSavedKeywordsInput, diff --git a/src/server/mcp/tools/list-saved-keywords.ts b/src/server/mcp/tools/list-saved-keywords.ts index 3e8fcd3..7ffd63f 100644 --- a/src/server/mcp/tools/list-saved-keywords.ts +++ b/src/server/mcp/tools/list-saved-keywords.ts @@ -1,4 +1,4 @@ -import type { z } from "zod"; +import { z } from "zod"; import { KeywordResearchService } from "@/server/features/keywords/services/KeywordResearchService"; import { mcpResponse } from "@/server/mcp/formatters"; import { buildProjectMeta } from "@/server/mcp/context"; @@ -7,6 +7,21 @@ import { projectIdSchema } from "@/server/mcp/schemas"; const inputSchema = { projectId: projectIdSchema, + search: z + .string() + .min(1) + .max(200) + .optional() + .describe("Optional keyword text filter."), + tags: z + .array(z.string().min(1).max(64)) + .max(20) + .optional() + .describe("Optional tag-name filters. Multiple tags match ANY tag."), + limit: z + .union([z.literal(50), z.literal(100), z.literal(250)]) + .optional() + .describe("Maximum rows to return. Defaults to 100."), } as const; export const listSavedKeywordsTool = { @@ -14,23 +29,33 @@ export const listSavedKeywordsTool = { config: { title: "List saved keywords", description: - "Lists keywords saved to a project (with cached metrics like search volume, difficulty, CPC if available). Free — reads from OpenSEO's database, no DataForSEO call.", + "Lists keywords saved to a project (with cached metrics like search volume, difficulty, CPC, and tags if available). Free — reads from OpenSEO's database, no DataForSEO call. Use tag filters when the user asks for a saved segment; multiple tags match ANY tag.", inputSchema, }, handler: withMcpProjectAuth( async (args: z.infer>, context) => { - const { rows } = await KeywordResearchService.getSavedKeywords({ - projectId: args.projectId, - }); + const { rows, totalCount, tags } = + await KeywordResearchService.getSavedKeywords({ + projectId: args.projectId, + search: args.search, + tagNames: args.tags, + page: 1, + pageSize: args.limit ?? 100, + sort: "createdAt", + order: "desc", + }); const text = rows.length === 0 ? "No saved keywords yet." - : `Saved keywords (${rows.length}):\n` + + : `Saved keywords (${rows.length} of ${totalCount}):\n` + rows - .map( - (r) => - `- ${r.keyword} vol:${r.searchVolume ?? "?"} kd:${r.keywordDifficulty ?? "?"} cpc:${r.cpc != null ? `$${r.cpc.toFixed(2)}` : "?"}`, - ) + .map((row) => { + const tagText = + row.tags.length > 0 + ? ` tags:${row.tags.map((tag) => tag.name).join(",")}` + : ""; + return `- ${row.keyword} vol:${row.searchVolume ?? "?"} kd:${row.keywordDifficulty ?? "?"} cpc:${row.cpc != null ? `$${row.cpc.toFixed(2)}` : "?"}${tagText}`; + }) .join("\n"); return mcpResponse({ text, @@ -39,7 +64,7 @@ export const listSavedKeywordsTool = { args.projectId, `/p/${args.projectId}/saved`, ), - structuredContent: { rows }, + structuredContent: { rows, totalCount, tags }, }); }, ), diff --git a/src/server/mcp/tools/save-keywords.ts b/src/server/mcp/tools/save-keywords.ts index a796921..35bc633 100644 --- a/src/server/mcp/tools/save-keywords.ts +++ b/src/server/mcp/tools/save-keywords.ts @@ -18,6 +18,19 @@ const inputSchema = { .min(1) .max(100) .describe("Keywords to save (1-100)."), + tags: z + .array(z.string().min(1).max(64)) + .max(20) + .optional() + .describe( + "Optional tags to attach to every saved keyword. Ask the user for explicit confirmation before using this, especially when saving many keywords or creating new tag names.", + ), + tagMode: z + .enum(["append", "replace"]) + .optional() + .describe( + "How to apply tags. Defaults to append. Use replace to remove existing tags from these saved keywords before applying the provided tags.", + ), locationCode: locationCodeSchema.optional(), languageCode: languageCodeSchema.optional(), } as const; @@ -29,18 +42,34 @@ export const saveKeywordsTool = { config: { title: "Save keywords", description: - "Save keywords to a project's saved-keywords list. Free — does not call DataForSEO. Idempotent: re-saving an existing keyword is a no-op.", + "Save keywords to a project's saved-keywords list. Free — does not call DataForSEO. Idempotent: re-saving an existing keyword is a no-op. If tags are provided, missing tags may be created. By default tags are appended; set tagMode=replace to remove existing tags from these saved keywords before applying the provided tags, which is useful for reorganizing keywords into page/topic clusters. Ask the user for confirmation before applying or replacing tags broadly.", inputSchema, }, handler: withMcpProjectAuth(async (args: Args, context) => { + if (args.tagMode === "replace" && (args.tags?.length ?? 0) === 0) { + throw new Error("Replacement tags are required when tagMode is replace."); + } + + const locationCode = args.locationCode ?? DEFAULT_LOCATION_CODE; + const languageCode = args.languageCode ?? DEFAULT_LANGUAGE_CODE; + await KeywordResearchService.saveKeywords({ projectId: args.projectId, keywords: args.keywords, - locationCode: args.locationCode ?? DEFAULT_LOCATION_CODE, - languageCode: args.languageCode ?? DEFAULT_LANGUAGE_CODE, + tags: args.tags, + tagMode: args.tagMode ?? "append", + locationCode, + languageCode, }); + + const tagText = + args.tags && args.tags.length > 0 + ? ` with tag(s): ${args.tags.join(", ")}` + : ""; + const modeText = args.tagMode === "replace" ? " Replaced tags." : ""; + return mcpResponse({ - text: `Saved ${args.keywords.length} keyword(s) to project ${args.projectId}.`, + text: `Saved ${args.keywords.length} keyword(s)${tagText} to project ${args.projectId}.${modeText}`, meta: buildProjectMeta( context, args.projectId, @@ -50,8 +79,10 @@ export const saveKeywordsTool = { projectId: args.projectId, savedCount: args.keywords.length, keywords: args.keywords, - locationCode: args.locationCode ?? DEFAULT_LOCATION_CODE, - languageCode: args.languageCode ?? DEFAULT_LANGUAGE_CODE, + tags: args.tags ?? [], + tagMode: args.tagMode ?? "append", + locationCode, + languageCode, }, }); }), diff --git a/src/server/mcp/tools/saved-keywords-tools.test.ts b/src/server/mcp/tools/saved-keywords-tools.test.ts new file mode 100644 index 0000000..af1ee83 --- /dev/null +++ b/src/server/mcp/tools/saved-keywords-tools.test.ts @@ -0,0 +1,191 @@ +import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js"; +import type { ToolExtra } from "@/server/mcp/context"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { MCP_AUTH_CONTEXT_PROP } from "@/server/mcp/context"; + +const mocks = vi.hoisted(() => ({ + getProjectForOrganization: vi.fn(), + getSavedKeywords: vi.fn(), + saveKeywords: vi.fn(), +})); + +vi.mock("@/server/features/projects/services/ProjectService", () => ({ + ProjectService: { + getProjectForOrganization: mocks.getProjectForOrganization, + }, +})); + +vi.mock("@/server/features/keywords/services/KeywordResearchService", () => ({ + KeywordResearchService: { + getSavedKeywords: mocks.getSavedKeywords, + saveKeywords: mocks.saveKeywords, + }, +})); + +const authContext = { + userId: "user_123", + userEmail: "alice@example.com", + organizationId: "org_123", + clientId: "client_123", + scopes: ["mcp"], + audience: "https://open-seo.test/mcp", + subject: "user_123", + baseUrl: "https://open-seo.test", +}; + +const toolExtra: ToolExtra = { + signal: new AbortController().signal, + requestId: 1, + sendNotification: vi.fn(), + sendRequest: vi.fn(), + authInfo: { + token: "token", + clientId: "client_123", + scopes: ["mcp"], + resource: new URL("https://open-seo.test/mcp"), + extra: { [MCP_AUTH_CONTEXT_PROP]: authContext }, + } satisfies AuthInfo, +}; + +describe("saved keyword MCP tools", () => { + beforeEach(() => { + vi.resetModules(); + mocks.getProjectForOrganization.mockReset(); + mocks.getProjectForOrganization.mockResolvedValue({ id: "project_1" }); + mocks.getSavedKeywords.mockReset(); + mocks.saveKeywords.mockReset(); + }); + + it("passes tags through save_keywords", async () => { + mocks.saveKeywords.mockResolvedValue({ + success: true, + savedKeywordIds: ["saved_1"], + }); + const { saveKeywordsTool } = await import("./save-keywords"); + + const result = await saveKeywordsTool.handler( + { + projectId: "project_1", + keywords: ["technical seo"], + tags: ["Content"], + }, + toolExtra, + ); + + expect(mocks.saveKeywords).toHaveBeenCalledWith({ + projectId: "project_1", + keywords: ["technical seo"], + tags: ["Content"], + tagMode: "append", + locationCode: 2840, + languageCode: "en", + }); + expect(result.structuredContent).toMatchObject({ + savedCount: 1, + tags: ["Content"], + tagMode: "append", + }); + }); + + it("replaces tags through save_keywords when requested", async () => { + mocks.saveKeywords.mockResolvedValue({ + success: true, + savedKeywordIds: ["saved_1", "saved_2"], + }); + const { saveKeywordsTool } = await import("./save-keywords"); + + const result = await saveKeywordsTool.handler( + { + projectId: "project_1", + keywords: ["semrush alternative", "semrush pricing"], + tags: ["cluster: affordable semrush alternatives"], + tagMode: "replace", + }, + toolExtra, + ); + + expect(mocks.saveKeywords).toHaveBeenCalledWith({ + projectId: "project_1", + keywords: ["semrush alternative", "semrush pricing"], + tags: ["cluster: affordable semrush alternatives"], + tagMode: "replace", + locationCode: 2840, + languageCode: "en", + }); + expect(result.structuredContent).toMatchObject({ + savedCount: 2, + tags: ["cluster: affordable semrush alternatives"], + tagMode: "replace", + }); + }); + + it("rejects replace mode without replacement tags before saving", async () => { + const { saveKeywordsTool } = await import("./save-keywords"); + + await expect(() => + saveKeywordsTool.handler( + { + projectId: "project_1", + keywords: ["semrush alternative"], + tagMode: "replace", + }, + toolExtra, + ), + ).rejects.toThrow("Replacement tags are required"); + expect(mocks.saveKeywords).not.toHaveBeenCalled(); + }); + + it("filters list_saved_keywords by search and tag names", async () => { + mocks.getSavedKeywords.mockResolvedValue({ + totalCount: 1, + tags: [ + { + id: "tag_1", + name: "Content", + normalizedName: "content", + keywordCount: 1, + }, + ], + rows: [ + { + id: "saved_1", + keyword: "technical seo", + searchVolume: 120, + keywordDifficulty: 18, + cpc: 2.5, + tags: [{ id: "tag_1", name: "Content", normalizedName: "content" }], + }, + ], + }); + const { listSavedKeywordsTool } = await import("./list-saved-keywords"); + + const result = await listSavedKeywordsTool.handler( + { + projectId: "project_1", + search: "technical", + tags: ["Content"], + limit: 50, + }, + toolExtra, + ); + + expect(mocks.getSavedKeywords).toHaveBeenCalledWith({ + projectId: "project_1", + search: "technical", + tagNames: ["Content"], + page: 1, + pageSize: 50, + sort: "createdAt", + order: "desc", + }); + expect(result.structuredContent).toMatchObject({ + totalCount: 1, + rows: [{ keyword: "technical seo" }], + }); + const [content] = result.content; + expect(content).toMatchObject({ type: "text" }); + expect(content?.type === "text" ? content.text : "").toContain( + "tags:Content", + ); + }); +}); diff --git a/src/serverFunctions/keywords.ts b/src/serverFunctions/keywords.ts index c59cbf6..2e93901 100644 --- a/src/serverFunctions/keywords.ts +++ b/src/serverFunctions/keywords.ts @@ -1,10 +1,14 @@ import { createServerFn } from "@tanstack/react-start"; import { + deleteSavedKeywordTagSchema, researchKeywordsSchema, saveKeywordsSchema, getSavedKeywordsSchema, + exportSavedKeywordsSchema, removeSavedKeywordsSchema, serpAnalysisSchema, + updateSavedKeywordTagSchema, + updateSavedKeywordTagsSchema, } from "@/types/schemas/keywords"; import { KeywordResearchService } from "@/server/features/keywords/services/KeywordResearchService"; import { requireProjectContext } from "@/serverFunctions/middleware"; @@ -42,6 +46,46 @@ export const getSavedKeywords = createServerFn({ method: "POST" }) }); }); +export const exportSavedKeywords = createServerFn({ method: "POST" }) + .middleware(requireProjectContext) + .inputValidator((data: unknown) => exportSavedKeywordsSchema.parse(data)) + .handler(async ({ data, context }) => { + return KeywordResearchService.exportSavedKeywords({ + ...data, + projectId: context.projectId, + }); + }); + +export const updateSavedKeywordTags = createServerFn({ method: "POST" }) + .middleware(requireProjectContext) + .inputValidator((data: unknown) => updateSavedKeywordTagsSchema.parse(data)) + .handler(async ({ data, context }) => { + return KeywordResearchService.updateSavedKeywordTags({ + ...data, + projectId: context.projectId, + }); + }); + +export const updateSavedKeywordTag = createServerFn({ method: "POST" }) + .middleware(requireProjectContext) + .inputValidator((data: unknown) => updateSavedKeywordTagSchema.parse(data)) + .handler(async ({ data, context }) => { + return KeywordResearchService.updateSavedKeywordTag({ + ...data, + projectId: context.projectId, + }); + }); + +export const deleteSavedKeywordTag = createServerFn({ method: "POST" }) + .middleware(requireProjectContext) + .inputValidator((data: unknown) => deleteSavedKeywordTagSchema.parse(data)) + .handler(async ({ data, context }) => { + return KeywordResearchService.deleteSavedKeywordTag({ + ...data, + projectId: context.projectId, + }); + }); + export const removeSavedKeywords = createServerFn({ method: "POST", }) diff --git a/src/shared/saved-keyword-tags.test.ts b/src/shared/saved-keyword-tags.test.ts new file mode 100644 index 0000000..9b9a9eb --- /dev/null +++ b/src/shared/saved-keyword-tags.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { + normalizeSavedKeywordTag, + normalizeSavedKeywordTags, + parseSavedKeywordTagInput, +} from "./saved-keyword-tags"; + +describe("saved keyword tag helpers", () => { + it("normalizes display and lookup names", () => { + expect(normalizeSavedKeywordTag(" Technical SEO ")).toEqual({ + name: "Technical SEO", + normalizedName: "technical seo", + }); + }); + + it("dedupes tags by normalized name", () => { + expect( + normalizeSavedKeywordTags(["Content", "content", " technical seo "]), + ).toEqual([ + { name: "Content", normalizedName: "content" }, + { name: "technical seo", normalizedName: "technical seo" }, + ]); + }); + + it("parses comma and newline separated tag input", () => { + expect(parseSavedKeywordTagInput("content, technical seo\nBOFU")).toEqual([ + "content", + "technical seo", + "BOFU", + ]); + }); +}); diff --git a/src/shared/saved-keyword-tags.ts b/src/shared/saved-keyword-tags.ts new file mode 100644 index 0000000..71a16c5 --- /dev/null +++ b/src/shared/saved-keyword-tags.ts @@ -0,0 +1,35 @@ +const TAG_SEPARATOR = /[\n,]+/; + +type NormalizedSavedKeywordTag = { + name: string; + normalizedName: string; +}; + +export function normalizeSavedKeywordTag( + value: string, +): NormalizedSavedKeywordTag | null { + const name = value.trim().replace(/\s+/g, " "); + if (name.length === 0) return null; + return { + name, + normalizedName: name.toLocaleLowerCase(), + }; +} + +export function normalizeSavedKeywordTags( + values: readonly string[] | undefined, +): NormalizedSavedKeywordTag[] { + const tags = new Map(); + for (const value of values ?? []) { + const tag = normalizeSavedKeywordTag(value); + if (!tag || tags.has(tag.normalizedName)) continue; + tags.set(tag.normalizedName, tag); + } + return [...tags.values()]; +} + +export function parseSavedKeywordTagInput(value: string): string[] { + return normalizeSavedKeywordTags(value.split(TAG_SEPARATOR)).map( + (tag) => tag.name, + ); +} diff --git a/src/shared/tag-colors.ts b/src/shared/tag-colors.ts new file mode 100644 index 0000000..a642fc1 --- /dev/null +++ b/src/shared/tag-colors.ts @@ -0,0 +1,58 @@ +export const TAG_COLOR_KEYS = [ + "slate", + "rose", + "amber", + "lime", + "emerald", + "sky", + "violet", + "fuchsia", +] as const; + +export type TagColorKey = (typeof TAG_COLOR_KEYS)[number]; + +function isTagColorKey(value: unknown): value is TagColorKey { + return ( + typeof value === "string" && + (TAG_COLOR_KEYS as readonly string[]).includes(value) + ); +} + +function hashString(value: string): number { + let hash = 0; + for (let i = 0; i < value.length; i++) { + hash = (hash * 31 + value.charCodeAt(i)) | 0; + } + return Math.abs(hash); +} + +export function resolveTagColor(tag: { + id: string; + color?: string | null; +}): TagColorKey { + if (isTagColorKey(tag.color)) return tag.color; + return TAG_COLOR_KEYS[hashString(tag.id) % TAG_COLOR_KEYS.length]; +} + +const COLOR_CLASS: Record = { + slate: "bg-slate-500", + rose: "bg-rose-500", + amber: "bg-amber-500", + lime: "bg-lime-500", + emerald: "bg-emerald-500", + sky: "bg-sky-500", + violet: "bg-violet-500", + fuchsia: "bg-fuchsia-500", +}; + +export function tagChipClass(color: TagColorKey): string { + return `tag-chip-${color} ring-1 ring-inset`; +} + +export function tagDotClass(color: TagColorKey): string { + return COLOR_CLASS[color]; +} + +export function tagSwatchClass(color: TagColorKey): string { + return COLOR_CLASS[color]; +} diff --git a/src/types/keywords.ts b/src/types/keywords.ts index b6b4fb7..0a29ea8 100644 --- a/src/types/keywords.ts +++ b/src/types/keywords.ts @@ -35,6 +35,19 @@ export type SavedKeywordRow = { intent: string | null; monthlySearches: MonthlySearch[]; fetchedAt: string | null; + tags: SavedKeywordTag[]; +}; + +export type SavedKeywordTag = { + id: string; + name: string; + normalizedName: string; + /** Palette key (e.g. "blue"). Null = derive a stable color from the id. */ + color: string | null; +}; + +export type SavedKeywordTagSummary = SavedKeywordTag & { + keywordCount: number; }; export type SerpResultItem = { diff --git a/src/types/schemas/keywords.ts b/src/types/schemas/keywords.ts index 0bcb7b6..1bc30ed 100644 --- a/src/types/schemas/keywords.ts +++ b/src/types/schemas/keywords.ts @@ -1,4 +1,18 @@ import { z } from "zod"; +import { TAG_COLOR_KEYS } from "@/shared/tag-colors"; + +const savedKeywordTagSchema = z.string().trim().min(1).max(64); +const tagColorSchema = z.enum(TAG_COLOR_KEYS); +const savedKeywordSortFields = [ + "createdAt", + "keyword", + "searchVolume", + "cpc", + "competition", + "keywordDifficulty", + "fetchedAt", +] as const; +const sortDirs = ["asc", "desc"] as const; export const researchKeywordsSchema = z.object({ projectId: z.string().min(1), @@ -14,49 +28,56 @@ export const researchKeywordsSchema = z.object({ .default("auto"), }); -export const saveKeywordsSchema = z.object({ - projectId: z.string().min(1), - keywords: z.array(z.string().min(1)).min(1).max(500), - locationCode: z.number().int().positive().default(2840), - languageCode: z.string().min(2).max(8).default("en"), - metrics: z - .array( - z.object({ - keyword: z.string().min(1), - searchVolume: z.number().int().nonnegative().nullable().optional(), - cpc: z.number().nonnegative().nullable().optional(), - competition: z.number().min(0).max(1).nullable().optional(), - keywordDifficulty: z - .number() - .int() - .min(0) - .max(100) - .nullable() - .optional(), - intent: z - .enum([ - "informational", - "commercial", - "transactional", - "navigational", - "unknown", - ]) - .nullable() - .optional(), - monthlySearches: z - .array( - z.object({ - year: z.number().int().positive(), - month: z.number().int().min(1).max(12), - searchVolume: z.number().int().nonnegative(), - }), - ) - .optional(), - }), - ) - .max(500) - .optional(), -}); +export const saveKeywordsSchema = z + .object({ + projectId: z.string().min(1), + keywords: z.array(z.string().min(1)).min(1).max(500), + locationCode: z.number().int().positive().default(2840), + languageCode: z.string().min(2).max(8).default("en"), + tags: z.array(savedKeywordTagSchema).max(20).optional(), + tagMode: z.enum(["append", "replace"]).optional(), + metrics: z + .array( + z.object({ + keyword: z.string().min(1), + searchVolume: z.number().int().nonnegative().nullable().optional(), + cpc: z.number().nonnegative().nullable().optional(), + competition: z.number().min(0).max(1).nullable().optional(), + keywordDifficulty: z + .number() + .int() + .min(0) + .max(100) + .nullable() + .optional(), + intent: z + .enum([ + "informational", + "commercial", + "transactional", + "navigational", + "unknown", + ]) + .nullable() + .optional(), + monthlySearches: z + .array( + z.object({ + year: z.number().int().positive(), + month: z.number().int().min(1).max(12), + searchVolume: z.number().int().nonnegative(), + }), + ) + .optional(), + }), + ) + .max(500) + .optional(), + }) + .refine( + (value) => value.tagMode !== "replace" || (value.tags?.length ?? 0) > 0, + "Replacement tags are required when tagMode is replace.", + ); export const removeSavedKeywordsSchema = z.object({ projectId: z.string().min(1), @@ -65,6 +86,58 @@ export const removeSavedKeywordsSchema = z.object({ export const getSavedKeywordsSchema = z.object({ projectId: z.string().min(1), + search: z.string().trim().max(200).optional(), + includeTerms: z.array(z.string().trim().min(1)).max(20).optional(), + excludeTerms: z.array(z.string().trim().min(1)).max(20).optional(), + minVolume: z.number().int().nonnegative().nullable().optional(), + maxVolume: z.number().int().nonnegative().nullable().optional(), + minCpc: z.number().nonnegative().nullable().optional(), + maxCpc: z.number().nonnegative().nullable().optional(), + minDifficulty: z.number().int().min(0).max(100).nullable().optional(), + maxDifficulty: z.number().int().min(0).max(100).nullable().optional(), + tagIds: z.array(z.string().min(1)).max(50).optional(), + tagNames: z.array(savedKeywordTagSchema).max(50).optional(), + page: z.number().int().positive().default(1), + pageSize: z + .union([z.literal(50), z.literal(100), z.literal(250)]) + .default(50), + sort: z.enum(savedKeywordSortFields).default("createdAt"), + order: z.enum(sortDirs).default("desc"), +}); + +export const exportSavedKeywordsSchema = getSavedKeywordsSchema.omit({ + page: true, + pageSize: true, +}); + +export const updateSavedKeywordTagsSchema = z + .object({ + projectId: z.string().min(1), + savedKeywordIds: z.array(z.string().min(1)).min(1).max(2000), + addTags: z.array(savedKeywordTagSchema).max(20).optional(), + removeTagIds: z.array(z.string().min(1)).max(50).optional(), + }) + .refine( + (value) => + (value.addTags?.length ?? 0) > 0 || (value.removeTagIds?.length ?? 0) > 0, + "Add or remove at least one tag.", + ); + +export const updateSavedKeywordTagSchema = z + .object({ + projectId: z.string().min(1), + tagId: z.string().min(1), + name: savedKeywordTagSchema.optional(), + color: tagColorSchema.nullable().optional(), + }) + .refine( + (value) => value.name !== undefined || value.color !== undefined, + "Provide a name or color to update.", + ); + +export const deleteSavedKeywordTagSchema = z.object({ + projectId: z.string().min(1), + tagId: z.string().min(1), }); export type ResearchKeywordsInput = z.infer; @@ -72,6 +145,19 @@ export type SaveKeywordsInput = z.infer; export type RemoveSavedKeywordsInput = z.infer< typeof removeSavedKeywordsSchema >; +export type GetSavedKeywordsInput = z.infer; +export type ExportSavedKeywordsInput = z.infer< + typeof exportSavedKeywordsSchema +>; +export type UpdateSavedKeywordTagsInput = z.infer< + typeof updateSavedKeywordTagsSchema +>; +export type UpdateSavedKeywordTagInput = z.infer< + typeof updateSavedKeywordTagSchema +>; +export type DeleteSavedKeywordTagInput = z.infer< + typeof deleteSavedKeywordTagSchema +>; export const serpAnalysisSchema = z.object({ projectId: z.string().min(1), keyword: z.string().min(1), @@ -79,8 +165,6 @@ export const serpAnalysisSchema = z.object({ languageCode: z.string().min(2).max(8).default("en"), }); -export type GetSavedKeywordsInput = z.infer; - /* ------------------------------------------------------------------ */ /* URL search params schema for /p/$projectId/keywords */ /* ------------------------------------------------------------------ */ @@ -93,7 +177,6 @@ const keywordSortFields = [ "keywordDifficulty", ] as const; -const sortDirs = ["asc", "desc"] as const; const keywordModes = ["auto", "related", "suggestions", "ideas"] as const; export const keywordsSearchSchema = z.object({