diff --git a/drizzle/0018_minor_mad_thinker.sql b/drizzle/0018_minor_mad_thinker.sql new file mode 100644 index 0000000..e4d5c26 --- /dev/null +++ b/drizzle/0018_minor_mad_thinker.sql @@ -0,0 +1,16 @@ +CREATE TABLE `user_onboarding_answers` ( + `user_id` text PRIMARY KEY NOT NULL, + `organization_id` text NOT NULL, + `interested_features` text DEFAULT '[]' NOT NULL, + `work_for` text, + `client_website_count` text, + `found_via` text, + `mcp_setup_intent` text, + `completed_at` text, + `created_at` text DEFAULT (current_timestamp) NOT NULL, + `updated_at` text DEFAULT (current_timestamp) NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`organization_id`) REFERENCES `organization`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `user_onboarding_answers_organization_idx` ON `user_onboarding_answers` (`organization_id`); \ No newline at end of file diff --git a/drizzle/meta/0018_snapshot.json b/drizzle/meta/0018_snapshot.json new file mode 100644 index 0000000..bb34ced --- /dev/null +++ b/drizzle/meta/0018_snapshot.json @@ -0,0 +1,2451 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "0356fb98-b07a-48a9-b12d-25015fcec53f", + "prevId": "e35850a7-60a5-4c9a-a8f5-875269e4b6da", + "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": { + "projects_one_default_per_organization_idx": { + "name": "projects_one_default_per_organization_idx", + "columns": [ + "organization_id" + ], + "isUnique": true, + "where": "\"projects\".\"name\" = 'Default' AND \"projects\".\"domain\" IS NULL" + } + }, + "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": {} + }, + "user_onboarding_answers": { + "name": "user_onboarding_answers", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "interested_features": { + "name": "interested_features", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "work_for": { + "name": "work_for", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_website_count": { + "name": "client_website_count", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "found_via": { + "name": "found_via", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mcp_setup_intent": { + "name": "mcp_setup_intent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_at": { + "name": "completed_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)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "user_onboarding_answers_organization_idx": { + "name": "user_onboarding_answers_organization_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_onboarding_answers_user_id_user_id_fk": { + "name": "user_onboarding_answers_user_id_user_id_fk", + "tableFrom": "user_onboarding_answers", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_onboarding_answers_organization_id_organization_id_fk": { + "name": "user_onboarding_answers_organization_id_organization_id_fk", + "tableFrom": "user_onboarding_answers", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invitation": { + "name": "invitation", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "invitation_organizationId_idx": { + "name": "invitation_organizationId_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + "email" + ], + "isUnique": false + } + }, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "member": { + "name": "member", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "member_organizationId_idx": { + "name": "member_organizationId_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "member_userId_idx": { + "name": "member_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organization": { + "name": "organization", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "organization_slug_uidx": { + "name": "organization_slug_uidx", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "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": {} + }, + "reddit_attributions": { + "name": "reddit_attributions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "click_id": { + "name": "click_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "uuid": { + "name": "uuid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "landing_page": { + "name": "landing_page", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "referrer": { + "name": "referrer", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "utm_source": { + "name": "utm_source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "utm_medium": { + "name": "utm_medium", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "utm_campaign": { + "name": "utm_campaign", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "utm_term": { + "name": "utm_term", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "utm_content": { + "name": "utm_content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "signup_sent_at": { + "name": "signup_sent_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "purchase_sent_at": { + "name": "purchase_sent_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)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "reddit_attributions_user_idx": { + "name": "reddit_attributions_user_idx", + "columns": [ + "user_id" + ], + "isUnique": true + }, + "reddit_attributions_organization_idx": { + "name": "reddit_attributions_organization_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "reddit_attributions_user_id_user_id_fk": { + "name": "reddit_attributions_user_id_user_id_fk", + "tableFrom": "reddit_attributions", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reddit_attributions_organization_id_organization_id_fk": { + "name": "reddit_attributions_organization_id_organization_id_fk", + "tableFrom": "reddit_attributions", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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 2b7fab9..0b4077c 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -127,6 +127,13 @@ "when": 1779331224828, "tag": "0017_omniscient_dagger", "breakpoints": true + }, + { + "idx": 18, + "version": "6", + "when": 1779849000462, + "tag": "0018_minor_mad_thinker", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/client/features/auth/AuthPage.tsx b/src/client/features/auth/AuthPage.tsx index 808c3bc..c7cad0a 100644 --- a/src/client/features/auth/AuthPage.tsx +++ b/src/client/features/auth/AuthPage.tsx @@ -55,7 +55,7 @@ export function AuthMethodChooser({
+
+ + +
+
+ ) : null} + + + ); +} + +function McpRecommendation({ + isSaving, + onBack, + onSetup, + onSkip, +}: { + isSaving: boolean; + onBack: () => void; + onSetup: () => void; + onSkip: () => void; +}) { + const capabilities = [ + "Keyword research", + "Competitor research", + "Link prospecting", + ]; + + return ( +
+ +

Set up OpenSEO MCP?

+

+ The most powerful way to use OpenSEO — use AI to supercharge your SEO + skills. +

+ + + + + +
+ ); +} + +function OnboardingChoiceGroup({ + title, + description, + reason, + options, + selectedValues, + onToggle, + otherValue, + onOtherChange, + multiple = false, + maxSelections, + followUp, +}: { + title: string; + description?: string; + reason?: string; + options: string[]; + selectedValues: string[]; + onToggle: (value: string) => void; + otherValue: string; + onOtherChange: (value: string) => void; + multiple?: boolean; + maxSelections?: number; + followUp?: { + showForValue: string; + label: string; + options: string[]; + value: string; + onChange: (value: string) => void; + }; +}) { + const isOtherSelected = selectedValues.includes("Other"); + const showFollowUp = + followUp !== undefined && selectedValues.includes(followUp.showForValue); + const atLimit = + maxSelections !== undefined && selectedValues.length >= maxSelections; + + return ( +
+
+

{title}

+ {description ? ( +

{description}

+ ) : null} + {reason ? ( +

+ {reason} +

+ ) : null} +
+ +
+ {options.map((option) => { + const selected = selectedValues.includes(option); + const disabled = atLimit && !selected; + const showFollowUpHere = + showFollowUp && followUp?.showForValue === option; + + return ( + + + + {showFollowUpHere && followUp ? ( +
+

+ {followUp.label} +

+
+ {followUp.options.map((followUpOption) => { + const followUpSelected = + followUp.value === followUpOption; + + return ( + + ); + })} +
+
+ ) : null} +
+ ); + })} +
+ + {isOtherSelected ? ( + onOtherChange(event.target.value)} + /> + ) : null} +
+ ); +} diff --git a/src/client/features/onboarding/onboardingModel.ts b/src/client/features/onboarding/onboardingModel.ts new file mode 100644 index 0000000..2224327 --- /dev/null +++ b/src/client/features/onboarding/onboardingModel.ts @@ -0,0 +1,139 @@ +import { queryOptions } from "@tanstack/react-query"; +import { getOnboardingAnswers } from "@/serverFunctions/onboarding"; + +export const ONBOARDING_LAST_STEP = 3; + +export const INTEREST_OPTIONS = [ + "AI workflows with Claude or Codex (MCP)", + "Keyword research", + "Competitor research", + "Backlink analysis", + "Site audits", + "Rank tracking", + "Other", +] as const; + +export const WORK_FOR_OPTIONS = [ + "My clients", + "My own startup or business", + "My employer's website", + "My own side project", + "I'm exploring before choosing a project", + "Other", +] as const; + +export const CLIENT_WORK_FOR = "My clients"; + +export const CLIENT_WEBSITE_COUNT_OPTIONS = [ + "1–3", + "4–10", + "11–25", + "25+", +] as const; + +export const SOURCE_OPTIONS = [ + "Google", + "Reddit", + "X / Twitter", + "GitHub", + "ChatGPT", + "Claude", + "Friend or colleague", + "Other", +] as const; + +/** In-progress form state. Step is tracked separately in the URL. */ +export type OnboardingAnswers = { + selectedInterests: string[]; + interestOther: string; + workFor: string; + workForOther: string; + clientWebsiteCount: string; + source: string; + sourceOther: string; +}; + +/** Answers as persisted in the DB (read back via getOnboardingAnswers). */ +type SavedOnboardingAnswers = { + interestedFeatures: string[]; + workFor: string | null; + clientWebsiteCount: string | null; + foundVia: string | null; + mcpSetupIntent: string | null; +}; + +export const onboardingAnswersQueryOptions = () => + queryOptions({ + queryKey: ["onboardingAnswers"], + queryFn: () => getOnboardingAnswers(), + }); + +// Saved answers normalize "Other" selections into free text, so restoring the +// UI means mapping any value that isn't a known option back onto "Other". +function restoreSingleChoice( + saved: string | null, + options: readonly string[], +): { value: string; other: string } { + if (!saved) return { value: "", other: "" }; + if (options.includes(saved)) return { value: saved, other: "" }; + return { value: "Other", other: saved }; +} + +export function restoreOnboardingAnswers( + saved: SavedOnboardingAnswers, +): OnboardingAnswers { + const known = saved.interestedFeatures.filter((value) => + (INTEREST_OPTIONS as readonly string[]).includes(value), + ); + const custom = saved.interestedFeatures.filter( + (value) => !(INTEREST_OPTIONS as readonly string[]).includes(value), + ); + const work = restoreSingleChoice(saved.workFor, WORK_FOR_OPTIONS); + const found = restoreSingleChoice(saved.foundVia, SOURCE_OPTIONS); + + return { + selectedInterests: custom.length > 0 ? [...known, "Other"] : known, + interestOther: custom[0] ?? "", + workFor: work.value, + workForOther: work.other, + clientWebsiteCount: + work.value === CLIENT_WORK_FOR ? (saved.clientWebsiteCount ?? "") : "", + source: found.value, + sourceOther: found.other, + }; +} + +/** + * Convert the in-progress form into the persisted payload. `step` decides which + * fields are mature enough to write so we don't clobber later answers on save. + */ +export function buildOnboardingPayload( + answers: OnboardingAnswers, + step: number, + extra: { mcpSetupIntent?: "yes" | "no"; completed?: boolean } = {}, +) { + const interestedFeatures = answers.selectedInterests.map((value) => + value === "Other" && answers.interestOther.trim() + ? answers.interestOther.trim() + : value, + ); + const workFor = + answers.workFor === "Other" && answers.workForOther.trim() + ? answers.workForOther.trim() + : answers.workFor || undefined; + // Only persist a client-site estimate when "My clients" is selected; clear it + // otherwise so a stale value from an earlier pass doesn't linger. + const clientWebsiteCount = + answers.workFor === CLIENT_WORK_FOR ? answers.clientWebsiteCount : ""; + const foundVia = + answers.source === "Other" && answers.sourceOther.trim() + ? answers.sourceOther.trim() + : answers.source || undefined; + + return { + ...(step >= 0 ? { interestedFeatures } : {}), + ...(step >= 1 ? { workFor, clientWebsiteCount } : {}), + ...(step >= 2 ? { foundVia } : {}), + ...extra, + }; +} diff --git a/src/client/features/onboarding/useOnboardingRedirect.ts b/src/client/features/onboarding/useOnboardingRedirect.ts new file mode 100644 index 0000000..5580981 --- /dev/null +++ b/src/client/features/onboarding/useOnboardingRedirect.ts @@ -0,0 +1,38 @@ +import { useQuery } from "@tanstack/react-query"; +import { useNavigate } from "@tanstack/react-router"; +import { useEffect } from "react"; +import { onboardingAnswersQueryOptions } from "@/client/features/onboarding/onboardingModel"; +import { useSession } from "@/lib/auth-client"; +import { isHostedClientAuthMode } from "@/lib/auth-mode"; + +export function useOnboardingRedirect() { + const navigate = useNavigate(); + const { data: session } = useSession(); + const isHostedMode = isHostedClientAuthMode(); + const onboardingQuery = useQuery({ + ...onboardingAnswersQueryOptions(), + enabled: isHostedMode && Boolean(session?.user?.id), + }); + + useEffect(() => { + if ( + !isHostedMode || + !session?.user?.id || + onboardingQuery.isLoading || + onboardingQuery.isError || + onboardingQuery.data?.completedAt || + window.location.pathname === "/onboarding" + ) { + return; + } + + void navigate({ to: "/onboarding", search: { step: 0 }, replace: true }); + }, [ + isHostedMode, + navigate, + onboardingQuery.data?.completedAt, + onboardingQuery.isError, + onboardingQuery.isLoading, + session?.user?.id, + ]); +} diff --git a/src/db/app.schema.ts b/src/db/app.schema.ts index 20c3a88..5300e51 100644 --- a/src/db/app.schema.ts +++ b/src/db/app.schema.ts @@ -7,7 +7,7 @@ import { index, } from "drizzle-orm/sqlite-core"; import { sql } from "drizzle-orm"; -import { organization } from "./better-auth-schema"; +import { organization, user } from "./better-auth-schema"; // This stores users for Cloudflare Access and local_noauth mode // since they don't map to better-auth's user schema @@ -19,6 +19,33 @@ export const delegatedUsers = sqliteTable("delegated_users", { .default(sql`(current_timestamp)`), }); +export const userOnboardingAnswers = sqliteTable( + "user_onboarding_answers", + { + userId: text("user_id") + .primaryKey() + .references(() => user.id, { onDelete: "cascade" }), + organizationId: text("organization_id") + .notNull() + .references(() => organization.id, { onDelete: "cascade" }), + interestedFeatures: text("interested_features").notNull().default("[]"), + workFor: text("work_for"), + clientWebsiteCount: text("client_website_count"), + foundVia: text("found_via"), + mcpSetupIntent: text("mcp_setup_intent"), + completedAt: text("completed_at"), + createdAt: text("created_at") + .notNull() + .default(sql`(current_timestamp)`), + updatedAt: text("updated_at") + .notNull() + .default(sql`(current_timestamp)`), + }, + (table) => [ + index("user_onboarding_answers_organization_idx").on(table.organizationId), + ], +); + // Projects for keyword research export const projects = sqliteTable( "projects", diff --git a/src/env.d.ts b/src/env.d.ts index bc1fda0..5312abb 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -7,6 +7,7 @@ declare namespace Cloudflare { OAUTH_KV: KVNamespace; AUTH_MODE?: "cloudflare_access" | "local_noauth" | "hosted"; + BYPASS_EMAIL_VERIFICATION?: string; TEAM_DOMAIN?: string; POLICY_AUD?: string; POSTHOG_PUBLIC_KEY?: string; @@ -26,6 +27,7 @@ declare namespace Cloudflare { interface ImportMetaEnv { readonly AUTH_MODE?: "cloudflare_access" | "local_noauth" | "hosted"; + readonly BYPASS_EMAIL_VERIFICATION?: string; readonly POSTHOG_PUBLIC_KEY?: string; readonly POSTHOG_HOST?: string; readonly VITE_E2E_DOMAIN_FIXTURES?: string; diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index b769dde..694028a 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -18,6 +18,7 @@ import { Route as ProjectRouteRouteImport } from './routes/_project/route' import { Route as AppRouteRouteImport } from './routes/_app/route' import { Route as AppIndexRouteImport } from './routes/_app/index' import { Route as AuthenticatedSubscribeRouteImport } from './routes/_authenticated.subscribe' +import { Route as AuthenticatedOnboardingRouteImport } from './routes/_authenticated.onboarding' import { Route as AuthenticatedOauthConsentRouteImport } from './routes/_authenticated.oauth-consent' import { Route as AuthSignUpRouteImport } from './routes/_auth.sign-up' import { Route as AuthSignInRouteImport } from './routes/_auth.sign-in' @@ -85,6 +86,11 @@ const AuthenticatedSubscribeRoute = AuthenticatedSubscribeRouteImport.update({ path: '/subscribe', getParentRoute: () => AuthenticatedRoute, } as any) +const AuthenticatedOnboardingRoute = AuthenticatedOnboardingRouteImport.update({ + id: '/onboarding', + path: '/onboarding', + getParentRoute: () => AuthenticatedRoute, +} as any) const AuthenticatedOauthConsentRoute = AuthenticatedOauthConsentRouteImport.update({ id: '/oauth-consent', @@ -235,6 +241,7 @@ export interface FileRoutesByFullPath { '/sign-in': typeof AuthSignInRoute '/sign-up': typeof AuthSignUpRoute '/oauth-consent': typeof AuthenticatedOauthConsentRoute + '/onboarding': typeof AuthenticatedOnboardingRoute '/subscribe': typeof AuthenticatedSubscribeRoute '/p/$projectId': typeof ProjectPProjectIdRouteRouteWithChildren '/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute @@ -267,6 +274,7 @@ export interface FileRoutesByTo { '/sign-in': typeof AuthSignInRoute '/sign-up': typeof AuthSignUpRoute '/oauth-consent': typeof AuthenticatedOauthConsentRoute + '/onboarding': typeof AuthenticatedOnboardingRoute '/subscribe': typeof AuthenticatedSubscribeRoute '/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute '/api/auth/$': typeof ApiAuthSplatRoute @@ -300,6 +308,7 @@ export interface FileRoutesById { '/_auth/sign-in': typeof AuthSignInRoute '/_auth/sign-up': typeof AuthSignUpRoute '/_authenticated/oauth-consent': typeof AuthenticatedOauthConsentRoute + '/_authenticated/onboarding': typeof AuthenticatedOnboardingRoute '/_authenticated/subscribe': typeof AuthenticatedSubscribeRoute '/_app/': typeof AppIndexRoute '/_project/p/$projectId': typeof ProjectPProjectIdRouteRouteWithChildren @@ -335,6 +344,7 @@ export interface FileRouteTypes { | '/sign-in' | '/sign-up' | '/oauth-consent' + | '/onboarding' | '/subscribe' | '/p/$projectId' | '/help/dataforseo-api-key' @@ -367,6 +377,7 @@ export interface FileRouteTypes { | '/sign-in' | '/sign-up' | '/oauth-consent' + | '/onboarding' | '/subscribe' | '/help/dataforseo-api-key' | '/api/auth/$' @@ -399,6 +410,7 @@ export interface FileRouteTypes { | '/_auth/sign-in' | '/_auth/sign-up' | '/_authenticated/oauth-consent' + | '/_authenticated/onboarding' | '/_authenticated/subscribe' | '/_app/' | '/_project/p/$projectId' @@ -498,6 +510,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedSubscribeRouteImport parentRoute: typeof AuthenticatedRoute } + '/_authenticated/onboarding': { + id: '/_authenticated/onboarding' + path: '/onboarding' + fullPath: '/onboarding' + preLoaderRoute: typeof AuthenticatedOnboardingRouteImport + parentRoute: typeof AuthenticatedRoute + } '/_authenticated/oauth-consent': { id: '/_authenticated/oauth-consent' path: '/oauth-consent' @@ -790,11 +809,13 @@ const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren) interface AuthenticatedRouteChildren { AuthenticatedOauthConsentRoute: typeof AuthenticatedOauthConsentRoute + AuthenticatedOnboardingRoute: typeof AuthenticatedOnboardingRoute AuthenticatedSubscribeRoute: typeof AuthenticatedSubscribeRoute } const AuthenticatedRouteChildren: AuthenticatedRouteChildren = { AuthenticatedOauthConsentRoute: AuthenticatedOauthConsentRoute, + AuthenticatedOnboardingRoute: AuthenticatedOnboardingRoute, AuthenticatedSubscribeRoute: AuthenticatedSubscribeRoute, } diff --git a/src/routes/_app/route.tsx b/src/routes/_app/route.tsx index 2cf05c9..f32524f 100644 --- a/src/routes/_app/route.tsx +++ b/src/routes/_app/route.tsx @@ -7,6 +7,7 @@ import { getCurrentAuthRedirectFromHref, getSignInSearch, } from "@/lib/auth-redirect"; +import { useOnboardingRedirect } from "@/client/features/onboarding/useOnboardingRedirect"; export const Route = createFileRoute("/_app")({ component: AppRouteLayout, @@ -16,6 +17,7 @@ function AppRouteLayout() { const navigate = useNavigate(); const { data: session, isPending } = useSession(); const isHostedMode = isHostedClientAuthMode(); + useOnboardingRedirect(); useEffect(() => { if (isPending || !isHostedMode || session?.user?.id) { diff --git a/src/routes/_auth.sign-up.tsx b/src/routes/_auth.sign-up.tsx index bb187bd..ac5c826 100644 --- a/src/routes/_auth.sign-up.tsx +++ b/src/routes/_auth.sign-up.tsx @@ -48,9 +48,12 @@ function SignUpPage() { const search = Route.useSearch(); const navigate = useNavigate(); const { redirectTo, isHostedMode } = useAuthPageState(search.redirect); + const postSignupRedirect = redirectTo === "/" ? "/onboarding" : redirectTo; const [showEmailForm, setShowEmailForm] = useState(false); const [isStartingGoogle, setIsStartingGoogle] = useState(false); const [socialError, setSocialError] = useState(null); + const bypassEmailVerification = + import.meta.env.BYPASS_EMAIL_VERIFICATION === "true"; const form = useForm({ defaultValues: { @@ -75,9 +78,15 @@ function SignUpPage() { email, password: value.password, callbackURL: (() => { + if (bypassEmailVerification) { + return new URL( + postSignupRedirect, + window.location.origin, + ).toString(); + } const url = new URL("/verify-email", window.location.origin); - if (redirectTo !== "/") { - url.searchParams.set("redirect", redirectTo); + if (postSignupRedirect !== "/") { + url.searchParams.set("redirect", postSignupRedirect); } return url.toString(); })(), @@ -96,9 +105,13 @@ function SignUpPage() { captureClientEvent("auth:sign_up_success", { redirect_to: redirectTo, }); + if (bypassEmailVerification) { + window.location.replace(postSignupRedirect); + return; + } void navigate({ to: "/verify-email", - search: { email, ...getSignInSearch(redirectTo) }, + search: { email, ...getSignInSearch(postSignupRedirect) }, }); } catch { formApi.setErrorMap({ @@ -112,18 +125,17 @@ function SignUpPage() { }); async function handleContinueWithGoogle() { - const callbackURL = redirectTo; setSocialError(null); setIsStartingGoogle(true); try { captureClientEvent("auth:sign_up_google_start", { - redirect_to: callbackURL, + redirect_to: redirectTo, }); const result = await authClient.signIn.social({ provider: "google", - callbackURL, - newUserCallbackURL: callbackURL, + callbackURL: redirectTo, + newUserCallbackURL: postSignupRedirect, requestSignUp: true, }); diff --git a/src/routes/_authenticated.onboarding.tsx b/src/routes/_authenticated.onboarding.tsx new file mode 100644 index 0000000..ddf08fc --- /dev/null +++ b/src/routes/_authenticated.onboarding.tsx @@ -0,0 +1,202 @@ +import { useMutation, useQuery } from "@tanstack/react-query"; +import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"; +import { Settings, User } from "lucide-react"; +import { useState } from "react"; +import { ThemePreferenceMenuItems } from "@/client/components/ThemePreferenceMenuItems"; +import { PostSignupOnboarding } from "@/client/features/onboarding/PostSignupOnboarding"; +import { + buildOnboardingPayload, + ONBOARDING_LAST_STEP, + type OnboardingAnswers, + onboardingAnswersQueryOptions, + restoreOnboardingAnswers, +} from "@/client/features/onboarding/onboardingModel"; +import { captureClientEvent } from "@/client/lib/posthog"; +import { queryClient } from "@/client/tanstack-db"; +import { signOutAndRedirect, useSession } from "@/lib/auth-client"; +import { saveOnboardingAnswers } from "@/serverFunctions/onboarding"; + +const ONBOARDING_EXISTING_USER_CUTOFF = "2026-05-27T00:00:00.000Z"; + +const clampStep = (step: number) => + Math.min(Math.max(0, Math.trunc(step)), ONBOARDING_LAST_STEP); + +export const Route = createFileRoute("/_authenticated/onboarding")({ + // Step lives in the URL so it survives refresh and works with back/forward. + validateSearch: (search: Record): { step: number } => { + const raw = Number(search.step); + return { step: Number.isFinite(raw) ? clampStep(raw) : 0 }; + }, + // Send users who already finished onboarding home before rendering. Running + // this in beforeLoad (not a component effect) means it can't race with the + // navigation we trigger after the final step. + beforeLoad: async () => { + const data = await queryClient.ensureQueryData( + onboardingAnswersQueryOptions(), + ); + if (data.completedAt) { + throw redirect({ to: "/", replace: true }); + } + }, + component: OnboardingPage, +}); + +function OnboardingPage() { + const { data: session } = useSession(); + const onboardingQuery = useQuery(onboardingAnswersQueryOptions()); + + if (!onboardingQuery.data) { + return null; + } + + const userCreatedAt = onboardingQuery.data.userCreatedAt + ? Date.parse(onboardingQuery.data.userCreatedAt) + : Date.now(); + const isExistingUser = + userCreatedAt < Date.parse(ONBOARDING_EXISTING_USER_CUTOFF); + const firstName = session?.user?.name?.split(" ")[0] || ""; + + return ( + + ); +} + +function OnboardingFlow({ + firstName, + isExistingUser, + initialAnswers, + email, +}: { + firstName: string; + isExistingUser: boolean; + initialAnswers: OnboardingAnswers; + email: string | undefined; +}) { + const navigate = useNavigate(); + const { step } = Route.useSearch(); + const [answers, setAnswers] = useState(initialAnswers); + + const saveMutation = useMutation({ + mutationFn: (extra: { + mcpSetupIntent?: "yes" | "no"; + completed?: boolean; + }) => + saveOnboardingAnswers({ + data: buildOnboardingPayload(answers, step, extra), + }), + onError: (error) => { + console.error("Failed to save onboarding answers", error); + }, + }); + + const goToStep = (next: number) => + void navigate({ to: "/onboarding", search: { step: clampStep(next) } }); + + const handleNext = () => { + if (step === 0) { + captureClientEvent("onboarding:interests_selected", { + interests: answers.selectedInterests, + interest_other: answers.interestOther.trim() || undefined, + }); + } + saveMutation.mutate({}); + goToStep(step + 1); + }; + + const handleSkip = () => { + saveMutation.mutate({}); + captureClientEvent("onboarding:step_skipped", { step }); + goToStep(step + 1); + }; + + const handleFinish = async (mcpSetupIntent: "yes" | "no") => { + try { + await saveMutation.mutateAsync({ mcpSetupIntent, completed: true }); + // Refresh the shared cache so the destination's onboarding-redirect guard + // sees the completed state and doesn't bounce the user back here. + await queryClient.invalidateQueries({ queryKey: ["onboardingAnswers"] }); + } catch { + // Already logged by the mutation's onError; still navigate the user on. + } + captureClientEvent("onboarding:completed", { + interests: answers.selectedInterests, + work_for: answers.workFor, + source: answers.source, + wants_mcp_setup: mcpSetupIntent === "yes", + }); + if (mcpSetupIntent === "yes") { + void navigate({ to: "/ai", replace: true }); + } else { + void navigate({ to: "/", replace: true }); + } + }; + + return ( + goToStep(step - 1)} + onSkip={handleSkip} + onFinish={handleFinish} + isSaving={saveMutation.isPending} + accountMenu={} + /> + ); +} + +function OnboardingAccountMenu({ email }: { email: string | undefined }) { + if (!email) return null; + + const handleSignOut = () => signOutAndRedirect(); + + return ( +
+
+ + +
+
+ ); +} diff --git a/src/routes/_project/p/$projectId/route.tsx b/src/routes/_project/p/$projectId/route.tsx index 0aabb40..1b2d7fb 100644 --- a/src/routes/_project/p/$projectId/route.tsx +++ b/src/routes/_project/p/$projectId/route.tsx @@ -1,5 +1,6 @@ import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"; import { FreePlanBanner } from "@/client/features/billing/FreePlanBanner"; +import { useOnboardingRedirect } from "@/client/features/onboarding/useOnboardingRedirect"; import { getErrorCode } from "@/client/lib/error-messages"; import { AuthenticatedAppLayout } from "@/client/layout/AppShell"; import { isHostedClientAuthMode } from "@/lib/auth-mode"; @@ -33,6 +34,7 @@ export const Route = createFileRoute("/_project/p/$projectId")({ function ProjectLayout() { const { projectId } = Route.useParams(); + useOnboardingRedirect(); return ( { - if (!isVerified) { + if (!isVerified && !bypassEmailVerification) { return; } - captureClientEvent("auth:verification_success", { - redirect_to: redirectTo, - }); + if (isVerified) { + captureClientEvent("auth:verification_success", { + redirect_to: redirectTo, + }); + } // Full page reload instead of client-side navigation: the auth→app // transition needs a clean server-side load so that all server function @@ -134,7 +138,7 @@ function VerifyEmailPage() { // hit the server before updated handlers are ready, causing // "action is not a function" errors). window.location.replace(redirectTo); - }, [isVerified, redirectTo]); + }, [bypassEmailVerification, isVerified, redirectTo]); useEffect(() => { if (!verificationIssueType) { diff --git a/src/serverFunctions/config.ts b/src/serverFunctions/config.ts index 1b46e33..a78a188 100644 --- a/src/serverFunctions/config.ts +++ b/src/serverFunctions/config.ts @@ -1,5 +1,5 @@ -import { createServerFn } from "@tanstack/react-start"; import { env } from "cloudflare:workers"; +import { createServerFn } from "@tanstack/react-start"; import { requireAuthenticatedContext } from "@/serverFunctions/middleware"; export const getSeoApiKeyStatus = createServerFn({ method: "GET" }) diff --git a/src/serverFunctions/onboarding.ts b/src/serverFunctions/onboarding.ts new file mode 100644 index 0000000..120a4f6 --- /dev/null +++ b/src/serverFunctions/onboarding.ts @@ -0,0 +1,106 @@ +import { createServerFn } from "@tanstack/react-start"; +import { eq } from "drizzle-orm"; +import { z } from "zod"; +import { user, userOnboardingAnswers } from "@/db/schema"; +import { db } from "@/db"; +import { requireAuthenticatedContext } from "@/serverFunctions/middleware"; + +const onboardingAnswersSchema = z.object({ + interestedFeatures: z.array(z.string()).optional(), + workFor: z.string().optional(), + clientWebsiteCount: z.string().optional(), + foundVia: z.string().optional(), + mcpSetupIntent: z.enum(["yes", "no"]).optional(), + completed: z.boolean().optional(), +}); + +export const getOnboardingAnswers = createServerFn({ method: "GET" }) + .middleware(requireAuthenticatedContext) + .handler(async ({ context }) => { + const answers = await db.query.userOnboardingAnswers.findFirst({ + columns: { + completedAt: true, + interestedFeatures: true, + workFor: true, + clientWebsiteCount: true, + foundVia: true, + mcpSetupIntent: true, + }, + where: eq(userOnboardingAnswers.userId, context.userId), + }); + const hostedUser = await db.query.user.findFirst({ + columns: { + createdAt: true, + }, + where: eq(user.id, context.userId), + }); + + let interestedFeatures: string[] = []; + if (answers?.interestedFeatures) { + try { + const parsed: unknown = JSON.parse(answers.interestedFeatures); + if (Array.isArray(parsed)) { + interestedFeatures = parsed.filter( + (value): value is string => typeof value === "string", + ); + } + } catch { + interestedFeatures = []; + } + } + + return { + completedAt: answers?.completedAt ?? null, + userCreatedAt: hostedUser?.createdAt?.toISOString() ?? null, + answers: { + interestedFeatures, + workFor: answers?.workFor ?? null, + clientWebsiteCount: answers?.clientWebsiteCount ?? null, + foundVia: answers?.foundVia ?? null, + mcpSetupIntent: answers?.mcpSetupIntent ?? null, + }, + }; + }); + +export const saveOnboardingAnswers = createServerFn({ method: "POST" }) + .middleware(requireAuthenticatedContext) + .inputValidator((data: unknown) => onboardingAnswersSchema.parse(data)) + .handler(async ({ data, context }) => { + const now = new Date().toISOString(); + const completedAt = data.completed ? now : undefined; + const set = { + ...(data.interestedFeatures + ? { interestedFeatures: JSON.stringify(data.interestedFeatures) } + : {}), + ...(data.workFor !== undefined ? { workFor: data.workFor } : {}), + ...(data.clientWebsiteCount !== undefined + ? { clientWebsiteCount: data.clientWebsiteCount } + : {}), + ...(data.foundVia !== undefined ? { foundVia: data.foundVia } : {}), + ...(data.mcpSetupIntent !== undefined + ? { mcpSetupIntent: data.mcpSetupIntent } + : {}), + ...(completedAt !== undefined ? { completedAt } : {}), + updatedAt: now, + }; + + await db + .insert(userOnboardingAnswers) + .values({ + userId: context.userId, + organizationId: context.organizationId, + interestedFeatures: JSON.stringify(data.interestedFeatures ?? []), + workFor: data.workFor, + clientWebsiteCount: data.clientWebsiteCount, + foundVia: data.foundVia, + mcpSetupIntent: data.mcpSetupIntent, + completedAt, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: userOnboardingAnswers.userId, + set, + }); + + return { ok: true }; + }); diff --git a/src/types/vite-env.d.ts b/src/types/vite-env.d.ts index 774a947..51380db 100644 --- a/src/types/vite-env.d.ts +++ b/src/types/vite-env.d.ts @@ -7,6 +7,7 @@ interface ViteTypeOptions { interface ImportMetaEnv { readonly VITE_SHOW_DEVTOOLS?: string; + readonly BYPASS_EMAIL_VERIFICATION?: string; // more env variables... } diff --git a/vite.config.ts b/vite.config.ts index b936a00..e35802e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -21,7 +21,13 @@ export default defineConfig(({ mode }) => { const emitSourcemaps = env.POSTHOG_SOURCEMAPS === "true"; return { - envPrefix: ["VITE_", "AUTH_MODE", "POSTHOG_PUBLIC_KEY", "POSTHOG_HOST"], + envPrefix: [ + "VITE_", + "AUTH_MODE", + "BYPASS_EMAIL_VERIFICATION", + "POSTHOG_PUBLIC_KEY", + "POSTHOG_HOST", + ], server: { allowedHosts, port,