diff --git a/drizzle/0021_autumn_billing.sql b/drizzle/0021_autumn_billing.sql new file mode 100644 index 0000000..bdf0a8d --- /dev/null +++ b/drizzle/0021_autumn_billing.sql @@ -0,0 +1,10 @@ +CREATE TABLE `billing_customer_status` ( + `organization_id` text PRIMARY KEY NOT NULL, + `is_paying` integer DEFAULT false NOT NULL, + `paid_plan_id` text, + `customer_json` text NOT NULL, + `synced_at` text NOT NULL, + `created_at` text DEFAULT (current_timestamp) NOT NULL, + `updated_at` text DEFAULT (current_timestamp) NOT NULL, + FOREIGN KEY (`organization_id`) REFERENCES `organization`(`id`) ON UPDATE no action ON DELETE cascade +); diff --git a/drizzle/meta/0021_snapshot.json b/drizzle/meta/0021_snapshot.json new file mode 100644 index 0000000..a5d7698 --- /dev/null +++ b/drizzle/meta/0021_snapshot.json @@ -0,0 +1,2604 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "1e4fa54c-ccab-4dcb-891b-76800265ee62", + "prevId": "53cb192d-34e5-4e32-b43a-ed3b6ddfc8ea", + "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": {} + }, + "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 + }, + "gsc_nudge_dismissed_at": { + "name": "gsc_nudge_dismissed_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": {} + }, + "billing_customer_status": { + "name": "billing_customer_status", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "is_paying": { + "name": "is_paying", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "paid_plan_id": { + "name": "paid_plan_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customer_json": { + "name": "customer_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "synced_at": { + "name": "synced_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "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": {}, + "foreignKeys": { + "billing_customer_status_organization_id_organization_id_fk": { + "name": "billing_customer_status_organization_id_organization_id_fk", + "tableFrom": "billing_customer_status", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "gsc_connections": { + "name": "gsc_connections", + "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 + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "site_url": { + "name": "site_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connected_by_user_id": { + "name": "connected_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connected_account_email": { + "name": "connected_account_email", + "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": { + "gsc_connections_project_idx": { + "name": "gsc_connections_project_idx", + "columns": [ + "project_id" + ], + "isUnique": true + }, + "gsc_connections_organization_idx": { + "name": "gsc_connections_organization_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "gsc_connections_project_id_projects_id_fk": { + "name": "gsc_connections_project_id_projects_id_fk", + "tableFrom": "gsc_connections", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "gsc_connections_organization_id_organization_id_fk": { + "name": "gsc_connections_organization_id_organization_id_fk", + "tableFrom": "gsc_connections", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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 7d41d03..5f86590 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -148,6 +148,13 @@ "when": 1780599087400, "tag": "0020_drop_delegated_users", "breakpoints": true + }, + { + "idx": 21, + "version": "6", + "when": 1780625404317, + "tag": "0021_autumn_billing", + "breakpoints": true } ] } \ No newline at end of file diff --git a/scripts/paying-customer-onboarding-answers.sql b/scripts/paying-customer-onboarding-answers.sql new file mode 100644 index 0000000..793c87c --- /dev/null +++ b/scripts/paying-customer-onboarding-answers.sql @@ -0,0 +1,23 @@ +SELECT + b.organization_id, + o.name AS organization_name, + b.is_paying, + b.paid_plan_id, + u.email, + u.name AS user_name, + a.work_for, + a.client_website_count, + a.found_via, + a.mcp_setup_intent, + a.interested_features, + a.completed_at AS onboarding_completed_at, + b.synced_at AS billing_synced_at +FROM billing_customer_status b +JOIN organization o + ON o.id = b.organization_id +LEFT JOIN user_onboarding_answers a + ON a.organization_id = b.organization_id +LEFT JOIN "user" u + ON u.id = a.user_id +WHERE b.is_paying = 1 +ORDER BY b.synced_at DESC, a.completed_at DESC; diff --git a/src/db/billing.schema.ts b/src/db/billing.schema.ts new file mode 100644 index 0000000..6c62a7e --- /dev/null +++ b/src/db/billing.schema.ts @@ -0,0 +1,21 @@ +import { sql } from "drizzle-orm"; +import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { organization } from "./better-auth-schema"; + +export const billingCustomerStatus = sqliteTable("billing_customer_status", { + organizationId: text("organization_id") + .primaryKey() + .references(() => organization.id, { onDelete: "cascade" }), + isPaying: integer("is_paying", { mode: "boolean" }).notNull().default(false), + paidPlanId: text("paid_plan_id"), + // Full Autumn customer payload — escape hatch for any field we don't flatten, + // queryable via json_extract so we never have to widen this table. + customerJson: text("customer_json").notNull(), + syncedAt: text("synced_at").notNull(), + createdAt: text("created_at") + .notNull() + .default(sql`(current_timestamp)`), + updatedAt: text("updated_at") + .notNull() + .default(sql`(current_timestamp)`), +}); diff --git a/src/db/schema.ts b/src/db/schema.ts index af34761..07fd7e5 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1,4 +1,5 @@ export * from "./app.schema"; export * from "./better-auth-schema"; +export * from "./billing.schema"; export * from "./gsc.schema"; export * from "./reddit-attribution.schema"; diff --git a/src/env.d.ts b/src/env.d.ts index 5312abb..057bc22 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -19,6 +19,8 @@ declare namespace Cloudflare { LOOPS_API_KEY?: string; LOOPS_TRANSACTIONAL_VERIFY_EMAIL_ID?: string; LOOPS_TRANSACTIONAL_RESET_PASSWORD_ID?: string; + AUTUMN_SECRET_KEY?: string; + AUTUMN_WEBHOOK_SECRET?: string; // DataForSEO API Basic auth value (base64 of login:password) DATAFORSEO_API_KEY: string; diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 64eabf0..0a1117b 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -29,10 +29,10 @@ import { Route as AppAiRouteImport } from './routes/_app/ai' import { Route as Char91DotwellKnownChar93OpenaiAppsChallengeRouteImport } from './routes/[.well-known]/openai-apps-challenge' import { Route as ApiAutumnSplatRouteImport } from './routes/api/autumn/$' import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$' -import { Route as ApiGscOauthCallbackRouteImport } from './routes/api/gsc/oauth/callback' import { Route as AppHelpDataforseoApiKeyRouteImport } from './routes/_app/help/dataforseo-api-key' import { Route as ProjectPProjectIdRouteRouteImport } from './routes/_project/p/$projectId/route' import { Route as ProjectPProjectIdIndexRouteImport } from './routes/_project/p/$projectId/index' +import { Route as ApiGscOauthCallbackRouteImport } from './routes/api/gsc/oauth/callback' import { Route as ProjectPProjectIdSavedRouteImport } from './routes/_project/p/$projectId/saved' import { Route as ProjectPProjectIdRankTrackingRouteImport } from './routes/_project/p/$projectId/rank-tracking' import { Route as ProjectPProjectIdPromptExplorerRouteImport } from './routes/_project/p/$projectId/prompt-explorer' @@ -145,11 +145,6 @@ const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({ path: '/api/auth/$', getParentRoute: () => rootRouteImport, } as any) -const ApiGscOauthCallbackRoute = ApiGscOauthCallbackRouteImport.update({ - id: '/api/gsc/oauth/callback', - path: '/api/gsc/oauth/callback', - getParentRoute: () => rootRouteImport, -} as any) const AppHelpDataforseoApiKeyRoute = AppHelpDataforseoApiKeyRouteImport.update({ id: '/help/dataforseo-api-key', path: '/help/dataforseo-api-key', @@ -165,6 +160,11 @@ const ProjectPProjectIdIndexRoute = ProjectPProjectIdIndexRouteImport.update({ path: '/', getParentRoute: () => ProjectPProjectIdRouteRoute, } as any) +const ApiGscOauthCallbackRoute = ApiGscOauthCallbackRouteImport.update({ + id: '/api/gsc/oauth/callback', + path: '/api/gsc/oauth/callback', + getParentRoute: () => rootRouteImport, +} as any) const ProjectPProjectIdSavedRoute = ProjectPProjectIdSavedRouteImport.update({ id: '/saved', path: '/saved', @@ -260,7 +260,6 @@ export interface FileRoutesByFullPath { '/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute '/api/auth/$': typeof ApiAuthSplatRoute '/api/autumn/$': typeof ApiAutumnSplatRoute - '/api/gsc/oauth/callback': typeof ApiGscOauthCallbackRoute '/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren '/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute '/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute @@ -270,6 +269,7 @@ export interface FileRoutesByFullPath { '/p/$projectId/prompt-explorer': typeof ProjectPProjectIdPromptExplorerRoute '/p/$projectId/rank-tracking': typeof ProjectPProjectIdRankTrackingRouteWithChildren '/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute + '/api/gsc/oauth/callback': typeof ApiGscOauthCallbackRoute '/p/$projectId/': typeof ProjectPProjectIdIndexRoute '/p/$projectId/rank-tracking/$configId': typeof ProjectPProjectIdRankTrackingConfigIdRoute '/p/$projectId/audit/': typeof ProjectPProjectIdAuditIndexRoute @@ -294,7 +294,6 @@ export interface FileRoutesByTo { '/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute '/api/auth/$': typeof ApiAuthSplatRoute '/api/autumn/$': typeof ApiAutumnSplatRoute - '/api/gsc/oauth/callback': typeof ApiGscOauthCallbackRoute '/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute '/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute '/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute @@ -302,6 +301,7 @@ export interface FileRoutesByTo { '/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute '/p/$projectId/prompt-explorer': typeof ProjectPProjectIdPromptExplorerRoute '/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute + '/api/gsc/oauth/callback': typeof ApiGscOauthCallbackRoute '/p/$projectId': typeof ProjectPProjectIdIndexRoute '/p/$projectId/rank-tracking/$configId': typeof ProjectPProjectIdRankTrackingConfigIdRoute '/p/$projectId/audit': typeof ProjectPProjectIdAuditIndexRoute @@ -332,7 +332,6 @@ export interface FileRoutesById { '/_app/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute '/api/auth/$': typeof ApiAuthSplatRoute '/api/autumn/$': typeof ApiAutumnSplatRoute - '/api/gsc/oauth/callback': typeof ApiGscOauthCallbackRoute '/_project/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren '/_project/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute '/_project/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute @@ -342,6 +341,7 @@ export interface FileRoutesById { '/_project/p/$projectId/prompt-explorer': typeof ProjectPProjectIdPromptExplorerRoute '/_project/p/$projectId/rank-tracking': typeof ProjectPProjectIdRankTrackingRouteWithChildren '/_project/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute + '/api/gsc/oauth/callback': typeof ApiGscOauthCallbackRoute '/_project/p/$projectId/': typeof ProjectPProjectIdIndexRoute '/_project/p/$projectId/rank-tracking/$configId': typeof ProjectPProjectIdRankTrackingConfigIdRoute '/_project/p/$projectId/audit/': typeof ProjectPProjectIdAuditIndexRoute @@ -369,7 +369,6 @@ export interface FileRouteTypes { | '/help/dataforseo-api-key' | '/api/auth/$' | '/api/autumn/$' - | '/api/gsc/oauth/callback' | '/p/$projectId/audit' | '/p/$projectId/backlinks' | '/p/$projectId/brand-lookup' @@ -379,6 +378,7 @@ export interface FileRouteTypes { | '/p/$projectId/prompt-explorer' | '/p/$projectId/rank-tracking' | '/p/$projectId/saved' + | '/api/gsc/oauth/callback' | '/p/$projectId/' | '/p/$projectId/rank-tracking/$configId' | '/p/$projectId/audit/' @@ -403,7 +403,6 @@ export interface FileRouteTypes { | '/help/dataforseo-api-key' | '/api/auth/$' | '/api/autumn/$' - | '/api/gsc/oauth/callback' | '/p/$projectId/backlinks' | '/p/$projectId/brand-lookup' | '/p/$projectId/domain' @@ -411,6 +410,7 @@ export interface FileRouteTypes { | '/p/$projectId/keywords' | '/p/$projectId/prompt-explorer' | '/p/$projectId/saved' + | '/api/gsc/oauth/callback' | '/p/$projectId' | '/p/$projectId/rank-tracking/$configId' | '/p/$projectId/audit' @@ -440,7 +440,6 @@ export interface FileRouteTypes { | '/_app/help/dataforseo-api-key' | '/api/auth/$' | '/api/autumn/$' - | '/api/gsc/oauth/callback' | '/_project/p/$projectId/audit' | '/_project/p/$projectId/backlinks' | '/_project/p/$projectId/brand-lookup' @@ -450,6 +449,7 @@ export interface FileRouteTypes { | '/_project/p/$projectId/prompt-explorer' | '/_project/p/$projectId/rank-tracking' | '/_project/p/$projectId/saved' + | '/api/gsc/oauth/callback' | '/_project/p/$projectId/' | '/_project/p/$projectId/rank-tracking/$configId' | '/_project/p/$projectId/audit/' @@ -613,13 +613,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiAuthSplatRouteImport parentRoute: typeof rootRouteImport } - '/api/gsc/oauth/callback': { - id: '/api/gsc/oauth/callback' - path: '/api/gsc/oauth/callback' - fullPath: '/api/gsc/oauth/callback' - preLoaderRoute: typeof ApiGscOauthCallbackRouteImport - parentRoute: typeof rootRouteImport - } '/_app/help/dataforseo-api-key': { id: '/_app/help/dataforseo-api-key' path: '/help/dataforseo-api-key' @@ -641,6 +634,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ProjectPProjectIdIndexRouteImport parentRoute: typeof ProjectPProjectIdRouteRoute } + '/api/gsc/oauth/callback': { + id: '/api/gsc/oauth/callback' + path: '/api/gsc/oauth/callback' + fullPath: '/api/gsc/oauth/callback' + preLoaderRoute: typeof ApiGscOauthCallbackRouteImport + parentRoute: typeof rootRouteImport + } '/_project/p/$projectId/saved': { id: '/_project/p/$projectId/saved' path: '/saved' diff --git a/src/server.ts b/src/server.ts index 6993b14..28bd587 100644 --- a/src/server.ts +++ b/src/server.ts @@ -15,6 +15,10 @@ import { requestWithPublicOrigin } from "@/server/mcp/public-origin"; import { MCP_ROUTE } from "@/server/mcp/context"; import { handleSelfHostedOpenSeoMcpRequest } from "@/server/mcp/transport"; import { computeNextCheckAt } from "@/shared/rank-tracking"; +import { + AUTUMN_WEBHOOK_PATH, + handleAutumnWebhookRequest, +} from "@/server/billing/autumn-webhook"; const appFetch = createStartHandler(defaultStreamHandler); const openSeoOAuthProvider = createOpenSeoOAuthProvider(appFetch); @@ -26,8 +30,13 @@ function fetch( ): Response | Promise { const authMode = getAuthMode(env.AUTH_MODE); const publicRequest = requestWithPublicOrigin(request); + const pathname = new URL(publicRequest.url).pathname; if (isHostedAuthMode(authMode)) { + if (pathname === AUTUMN_WEBHOOK_PATH) { + return handleAutumnWebhookRequest(publicRequest); + } + return openSeoOAuthProvider.fetch( publicRequest, env as OpenSeoOAuthEnv, @@ -37,7 +46,7 @@ function fetch( if ( (authMode === "cloudflare_access" || authMode === "local_noauth") && - new URL(publicRequest.url).pathname === MCP_ROUTE + pathname === MCP_ROUTE ) { return handleSelfHostedOpenSeoMcpRequest(publicRequest, authMode, env, ctx); } diff --git a/src/server/billing/autumn-webhook.ts b/src/server/billing/autumn-webhook.ts new file mode 100644 index 0000000..e565d08 --- /dev/null +++ b/src/server/billing/autumn-webhook.ts @@ -0,0 +1,74 @@ +import { z } from "zod"; +import { getRequiredEnvValue } from "@/server/lib/runtime-env"; +import { syncAutumnCustomerStatus } from "./customer-status-sync"; +import { verifySvixSignature } from "./svix"; + +export const AUTUMN_WEBHOOK_PATH = "/api/autumn/webhook"; + +const autumnWebhookPayloadSchema = z + .object({ + type: z.string(), + data: z.record(z.string(), z.unknown()).optional().default({}), + }) + .passthrough(); + +type AutumnWebhookPayload = z.infer; + +export async function handleAutumnWebhookRequest(request: Request) { + if (request.method !== "POST") { + return new Response("Method not allowed", { + headers: { Allow: "POST" }, + status: 405, + }); + } + + const rawPayload = await request.text(); + const webhookSecret = await getRequiredEnvValue("AUTUMN_WEBHOOK_SECRET"); + const isVerified = await verifySvixSignature({ + headers: request.headers, + payload: rawPayload, + secret: webhookSecret, + }); + + if (!isVerified) { + return json({ error: "Invalid webhook signature" }, 401); + } + + let payload: AutumnWebhookPayload; + try { + payload = autumnWebhookPayloadSchema.parse(JSON.parse(rawPayload)); + } catch { + return json({ error: "Invalid webhook payload" }, 400); + } + + // Re-syncing the customer's current state from Autumn is idempotent, so a + // replayed or out-of-order webhook simply converges to the same row — no + // dedup table needed. Svix retries on a non-2xx response. + if (payload.type === "billing.updated") { + const customerId = getCustomerId(payload); + if (!customerId) { + return json({ error: "Missing customer_id" }, 400); + } + + try { + await syncAutumnCustomerStatus(customerId); + } catch (error) { + console.error("Autumn billing.updated sync failed", error); + return json({ error: "Webhook processing failed" }, 500); + } + } + + return json({ received: true }); +} + +function getCustomerId(payload: AutumnWebhookPayload) { + const value = payload.data.customer_id; + return typeof value === "string" && value.length > 0 ? value : null; +} + +function json(data: Record, status = 200) { + return new Response(JSON.stringify(data), { + headers: { "Content-Type": "application/json" }, + status, + }); +} diff --git a/src/server/billing/customer-status-model.test.ts b/src/server/billing/customer-status-model.test.ts new file mode 100644 index 0000000..01603bb --- /dev/null +++ b/src/server/billing/customer-status-model.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { AUTUMN_PAID_PLAN_ID } from "@/shared/billing"; +import { deriveBillingCustomerStatusSnapshot } from "./customer-status-model"; + +describe("deriveBillingCustomerStatusSnapshot", () => { + it("marks customers with an active paid subscription as paying", () => { + const snapshot = deriveBillingCustomerStatusSnapshot({ + id: "org_123", + subscriptions: [{ planId: AUTUMN_PAID_PLAN_ID, status: "active" }], + }); + + expect(snapshot).toMatchObject({ + organizationId: "org_123", + isPaying: true, + paidPlanId: AUTUMN_PAID_PLAN_ID, + }); + }); + + it("preserves the full customer payload in customerJson", () => { + const snapshot = deriveBillingCustomerStatusSnapshot({ + id: "org_123", + email: "alice@example.com", + stripeId: "cus_123", + subscriptions: [{ planId: AUTUMN_PAID_PLAN_ID, status: "active" }], + }); + + expect(JSON.parse(snapshot.customerJson)).toMatchObject({ + id: "org_123", + email: "alice@example.com", + stripeId: "cus_123", + }); + }); + + it("keeps non-paid customers queryable but not paying", () => { + const snapshot = deriveBillingCustomerStatusSnapshot({ + id: "org_123", + subscriptions: [{ planId: "free", status: "active" }], + }); + + expect(snapshot.isPaying).toBe(false); + expect(snapshot.paidPlanId).toBeNull(); + }); + + it("records a scheduled (not-yet-active) paid plan as not paying", () => { + const snapshot = deriveBillingCustomerStatusSnapshot({ + id: "org_456", + subscriptions: [{ planId: AUTUMN_PAID_PLAN_ID, status: "scheduled" }], + }); + + expect(snapshot).toMatchObject({ + organizationId: "org_456", + isPaying: false, + paidPlanId: AUTUMN_PAID_PLAN_ID, + }); + }); + + it("prefers an active paid subscription when multiple paid rows exist", () => { + const snapshot = deriveBillingCustomerStatusSnapshot({ + id: "org_789", + subscriptions: [ + { planId: AUTUMN_PAID_PLAN_ID, status: "scheduled" }, + { planId: AUTUMN_PAID_PLAN_ID, status: "active" }, + ], + }); + + expect(snapshot.isPaying).toBe(true); + expect(snapshot.paidPlanId).toBe(AUTUMN_PAID_PLAN_ID); + }); +}); diff --git a/src/server/billing/customer-status-model.ts b/src/server/billing/customer-status-model.ts new file mode 100644 index 0000000..e3a1d12 --- /dev/null +++ b/src/server/billing/customer-status-model.ts @@ -0,0 +1,50 @@ +import { AUTUMN_PAID_PLAN_ID } from "@/shared/billing"; + +// The subset of the Autumn SDK's `Customer` we read. The SDK already validates +// and returns camelCase, so we type against it structurally instead of +// re-parsing; everything else is preserved verbatim in `customerJson`. +type AutumnSubscriptionInput = { + planId?: string | null; + status?: string | null; +}; + +type AutumnCustomerInput = { + id?: string | null; + subscriptions?: AutumnSubscriptionInput[]; + [key: string]: unknown; +}; + +export type BillingCustomerStatusSnapshot = { + organizationId: string; + isPaying: boolean; + paidPlanId: string | null; + customerJson: string; + syncedAt: string; +}; + +export function deriveBillingCustomerStatusSnapshot( + customer: AutumnCustomerInput, +): BillingCustomerStatusSnapshot { + const organizationId = customer.id; + if (!organizationId) { + throw new Error("Autumn customer is missing an id"); + } + + const subscription = selectPaidSubscription(customer.subscriptions ?? []); + + return { + organizationId, + isPaying: subscription?.status === "active", + paidPlanId: subscription?.planId ?? null, + // Full payload kept verbatim — query rarely-used fields via json_extract. + customerJson: JSON.stringify(customer), + syncedAt: new Date().toISOString(), + }; +} + +// A customer is "paying" when they hold the base paid plan. Prefer an active +// row, but fall back to any paid row so a not-yet-active plan still records its id. +function selectPaidSubscription(subscriptions: AutumnSubscriptionInput[]) { + const paid = subscriptions.filter((s) => s.planId === AUTUMN_PAID_PLAN_ID); + return paid.find((s) => s.status === "active") ?? paid[0] ?? null; +} diff --git a/src/server/billing/customer-status-sync.ts b/src/server/billing/customer-status-sync.ts new file mode 100644 index 0000000..ecd19ca --- /dev/null +++ b/src/server/billing/customer-status-sync.ts @@ -0,0 +1,35 @@ +import { sql } from "drizzle-orm"; +import { db } from "@/db"; +import { billingCustomerStatus } from "@/db/schema"; +import { autumn } from "@/server/billing/autumn"; +import { + deriveBillingCustomerStatusSnapshot, + type BillingCustomerStatusSnapshot, +} from "./customer-status-model"; + +export async function syncAutumnCustomerStatus(customerId: string) { + // getOrCreate is effectively a "get" here — a billing.updated webhook always + // references an existing Autumn customer. The SDK returns the camelCase shape. + const customer = await autumn.customers.getOrCreate({ customerId }); + const snapshot = deriveBillingCustomerStatusSnapshot(customer); + await upsertBillingCustomerStatus(snapshot); + return snapshot; +} + +async function upsertBillingCustomerStatus( + snapshot: BillingCustomerStatusSnapshot, +) { + await db + .insert(billingCustomerStatus) + .values(snapshot) + .onConflictDoUpdate({ + target: billingCustomerStatus.organizationId, + set: { + isPaying: snapshot.isPaying, + paidPlanId: snapshot.paidPlanId, + customerJson: snapshot.customerJson, + syncedAt: snapshot.syncedAt, + updatedAt: sql`(current_timestamp)`, + }, + }); +} diff --git a/src/server/billing/svix.test.ts b/src/server/billing/svix.test.ts new file mode 100644 index 0000000..1aa8889 --- /dev/null +++ b/src/server/billing/svix.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from "vitest"; +import { verifySvixSignature } from "./svix"; + +const SECRET_BYTES = "test-webhook-secret"; +const SECRET = `whsec_${Buffer.from(SECRET_BYTES).toString("base64")}`; + +describe("verifySvixSignature", () => { + it("accepts a valid Svix signature", async () => { + const payload = JSON.stringify({ type: "billing.updated" }); + const headers = await signedHeaders({ + id: "msg_123", + payload, + timestamp: 1_000, + }); + + await expect( + verifySvixSignature({ + headers, + nowSeconds: 1_000, + payload, + secret: SECRET, + }), + ).resolves.toBe(true); + }); + + it("rejects tampered payloads", async () => { + const payload = JSON.stringify({ type: "billing.updated" }); + const headers = await signedHeaders({ + id: "msg_123", + payload, + timestamp: 1_000, + }); + + await expect( + verifySvixSignature({ + headers, + nowSeconds: 1_000, + payload: JSON.stringify({ type: "other" }), + secret: SECRET, + }), + ).resolves.toBe(false); + }); + + it("rejects stale timestamps", async () => { + const payload = JSON.stringify({ type: "billing.updated" }); + const headers = await signedHeaders({ + id: "msg_123", + payload, + timestamp: 1_000, + }); + + await expect( + verifySvixSignature({ + headers, + nowSeconds: 1_400, + payload, + secret: SECRET, + }), + ).resolves.toBe(false); + }); + + it("rejects malformed signatures without throwing", async () => { + const payload = JSON.stringify({ type: "billing.updated" }); + const headers = new Headers({ + "svix-id": "msg_123", + "svix-signature": "v1,not-base64!", + "svix-timestamp": "1000", + }); + + await expect( + verifySvixSignature({ + headers, + nowSeconds: 1_000, + payload, + secret: SECRET, + }), + ).resolves.toBe(false); + }); + + it("accepts Webhook-prefixed headers", async () => { + const payload = JSON.stringify({ type: "billing.updated" }); + const timestamp = 1_000; + const id = "msg_123"; + const signature = await sign(`${id}.${timestamp}.${payload}`); + const headers = new Headers({ + "webhook-id": id, + "webhook-signature": `v1,${signature}`, + "webhook-timestamp": String(timestamp), + }); + + await expect( + verifySvixSignature({ + headers, + nowSeconds: timestamp, + payload, + secret: SECRET, + }), + ).resolves.toBe(true); + }); +}); + +async function signedHeaders(args: { + id: string; + payload: string; + timestamp: number; +}) { + const signature = await sign(`${args.id}.${args.timestamp}.${args.payload}`); + + return new Headers({ + "svix-id": args.id, + "svix-signature": `v1,${signature}`, + "svix-timestamp": String(args.timestamp), + }); +} + +async function sign(value: string) { + const key = await crypto.subtle.importKey( + "raw", + Buffer.from(SECRET_BYTES), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const signature = await crypto.subtle.sign( + "HMAC", + key, + new TextEncoder().encode(value), + ); + + return Buffer.from(signature).toString("base64"); +} diff --git a/src/server/billing/svix.ts b/src/server/billing/svix.ts new file mode 100644 index 0000000..4017f73 --- /dev/null +++ b/src/server/billing/svix.ts @@ -0,0 +1,95 @@ +const SIGNATURE_TOLERANCE_SECONDS = 5 * 60; + +export async function verifySvixSignature(args: { + headers: Headers; + payload: string; + secret: string; + nowSeconds?: number; +}) { + const id = getHeader(args.headers, "svix-id", "webhook-id"); + const timestamp = getHeader( + args.headers, + "svix-timestamp", + "webhook-timestamp", + ); + const signatureHeader = getHeader( + args.headers, + "svix-signature", + "webhook-signature", + ); + + if (!id || !timestamp || !signatureHeader) { + return false; + } + + const timestampSeconds = Number(timestamp); + if (!Number.isFinite(timestampSeconds)) { + return false; + } + + const nowSeconds = args.nowSeconds ?? Math.floor(Date.now() / 1000); + if (Math.abs(nowSeconds - timestampSeconds) > SIGNATURE_TOLERANCE_SECONDS) { + return false; + } + + const signedContent = `${id}.${timestamp}.${args.payload}`; + const expected = await hmacSha256(args.secret, signedContent); + + return signatureHeader.split(" ").some((signaturePart) => { + const [version, signature] = signaturePart.split(",", 2); + if (version !== "v1" || !signature) return false; + + try { + return constantTimeEqual(expected, base64ToBytes(signature)); + } catch { + return false; + } + }); +} + +function getHeader(headers: Headers, ...names: string[]) { + for (const name of names) { + const value = headers.get(name); + if (value) return value; + } +} + +async function hmacSha256(secret: string, value: string) { + const rawSecret = secret.startsWith("whsec_") ? secret.slice(6) : secret; + const key = await crypto.subtle.importKey( + "raw", + base64ToBytes(rawSecret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const signature = await crypto.subtle.sign( + "HMAC", + key, + new TextEncoder().encode(value), + ); + + return new Uint8Array(signature); +} + +function base64ToBytes(value: string) { + const decoded = atob(value); + const bytes = new Uint8Array(decoded.length); + + for (let i = 0; i < decoded.length; i += 1) { + bytes[i] = decoded.charCodeAt(i); + } + + return bytes; +} + +function constantTimeEqual(left: Uint8Array, right: Uint8Array) { + if (left.length !== right.length) return false; + + let difference = 0; + for (let i = 0; i < left.length; i += 1) { + difference |= left[i] ^ right[i]; + } + + return difference === 0; +}