diff --git a/drizzle/0010_high_liz_osborn.sql b/drizzle/0010_high_liz_osborn.sql new file mode 100644 index 0000000..446f86f --- /dev/null +++ b/drizzle/0010_high_liz_osborn.sql @@ -0,0 +1 @@ +ALTER TABLE `user` ADD `analytics_opted_out` integer; \ No newline at end of file diff --git a/drizzle/meta/0010_snapshot.json b/drizzle/meta/0010_snapshot.json new file mode 100644 index 0000000..fa7c5ce --- /dev/null +++ b/drizzle/meta/0010_snapshot.json @@ -0,0 +1,2038 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "c5ffffad-fce7-44dd-9009-c9880c7cb8c0", + "prevId": "ca0b9777-ad37-41d9-bb48-7b9f9f8fbf53", + "tables": { + "audit_lighthouse_results": { + "name": "audit_lighthouse_results", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "audit_id": { + "name": "audit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "page_id": { + "name": "page_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "strategy": { + "name": "strategy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "performance_score": { + "name": "performance_score", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "accessibility_score": { + "name": "accessibility_score", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "best_practices_score": { + "name": "best_practices_score", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "seo_score": { + "name": "seo_score", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lcp_ms": { + "name": "lcp_ms", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cls": { + "name": "cls", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inp_ms": { + "name": "inp_ms", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ttfb_ms": { + "name": "ttfb_ms", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "r2_key": { + "name": "r2_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload_size_bytes": { + "name": "payload_size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "audit_lighthouse_results_audit_id_idx": { + "name": "audit_lighthouse_results_audit_id_idx", + "columns": [ + "audit_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "audit_lighthouse_results_audit_id_audits_id_fk": { + "name": "audit_lighthouse_results_audit_id_audits_id_fk", + "tableFrom": "audit_lighthouse_results", + "tableTo": "audits", + "columnsFrom": [ + "audit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audit_lighthouse_results_page_id_audit_pages_id_fk": { + "name": "audit_lighthouse_results_page_id_audit_pages_id_fk", + "tableFrom": "audit_lighthouse_results", + "tableTo": "audit_pages", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audit_pages": { + "name": "audit_pages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "audit_id": { + "name": "audit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "redirect_url": { + "name": "redirect_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "meta_description": { + "name": "meta_description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "canonical_url": { + "name": "canonical_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "robots_meta": { + "name": "robots_meta", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "og_title": { + "name": "og_title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "og_description": { + "name": "og_description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "og_image": { + "name": "og_image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "h1_count": { + "name": "h1_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "h2_count": { + "name": "h2_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "h3_count": { + "name": "h3_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "h4_count": { + "name": "h4_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "h5_count": { + "name": "h5_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "h6_count": { + "name": "h6_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "heading_order_json": { + "name": "heading_order_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "word_count": { + "name": "word_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "images_total": { + "name": "images_total", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "images_missing_alt": { + "name": "images_missing_alt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "images_json": { + "name": "images_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "internal_link_count": { + "name": "internal_link_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "external_link_count": { + "name": "external_link_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "has_structured_data": { + "name": "has_structured_data", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "hreflang_tags_json": { + "name": "hreflang_tags_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_indexable": { + "name": "is_indexable", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "response_time_ms": { + "name": "response_time_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "audit_pages_audit_id_idx": { + "name": "audit_pages_audit_id_idx", + "columns": [ + "audit_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "audit_pages_audit_id_audits_id_fk": { + "name": "audit_pages_audit_id_audits_id_fk", + "tableFrom": "audit_pages", + "tableTo": "audits", + "columnsFrom": [ + "audit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audits": { + "name": "audits", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_by_user_id": { + "name": "started_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "start_url": { + "name": "start_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'running'" + }, + "workflow_instance_id": { + "name": "workflow_instance_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "pages_crawled": { + "name": "pages_crawled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "pages_total": { + "name": "pages_total", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "lighthouse_total": { + "name": "lighthouse_total", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "lighthouse_completed": { + "name": "lighthouse_completed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "lighthouse_failed": { + "name": "lighthouse_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "current_phase": { + "name": "current_phase", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'discovery'" + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "audits_project_id_idx": { + "name": "audits_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "audits_started_by_user_id_idx": { + "name": "audits_started_by_user_id_idx", + "columns": [ + "started_by_user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "audits_project_id_projects_id_fk": { + "name": "audits_project_id_projects_id_fk", + "tableFrom": "audits", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "delegated_users": { + "name": "delegated_users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "delegated_users_email_unique": { + "name": "delegated_users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "keyword_metrics": { + "name": "keyword_metrics", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyword": { + "name": "keyword", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "location_code": { + "name": "location_code", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "language_code": { + "name": "language_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'en'" + }, + "search_volume": { + "name": "search_volume", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cpc": { + "name": "cpc", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "competition": { + "name": "competition", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "keyword_difficulty": { + "name": "keyword_difficulty", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "intent": { + "name": "intent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "monthly_searches": { + "name": "monthly_searches", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fetched_at": { + "name": "fetched_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "keyword_metrics_unique_project_keyword_location_language": { + "name": "keyword_metrics_unique_project_keyword_location_language", + "columns": [ + "project_id", + "keyword", + "location_code", + "language_code" + ], + "isUnique": true + }, + "keyword_metrics_lookup_idx": { + "name": "keyword_metrics_lookup_idx", + "columns": [ + "project_id", + "keyword", + "location_code", + "language_code", + "fetched_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "keyword_metrics_project_id_projects_id_fk": { + "name": "keyword_metrics_project_id_projects_id_fk", + "tableFrom": "keyword_metrics", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": { + "projects_organization_id_organization_id_fk": { + "name": "projects_organization_id_organization_id_fk", + "tableFrom": "projects", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rank_check_locks": { + "name": "rank_check_locks", + "columns": { + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "acquired_at": { + "name": "acquired_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "rank_check_locks_run_idx": { + "name": "rank_check_locks_run_idx", + "columns": [ + "run_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "rank_check_locks_config_id_rank_tracking_configs_id_fk": { + "name": "rank_check_locks_config_id_rank_tracking_configs_id_fk", + "tableFrom": "rank_check_locks", + "tableTo": "rank_tracking_configs", + "columnsFrom": [ + "config_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 + } + }, + "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_keywords": { + "name": "saved_keywords", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyword": { + "name": "keyword", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "location_code": { + "name": "location_code", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 2840 + }, + "language_code": { + "name": "language_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'en'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "saved_keywords_unique_project_keyword_location_language": { + "name": "saved_keywords_unique_project_keyword_location_language", + "columns": [ + "project_id", + "keyword", + "location_code", + "language_code" + ], + "isUnique": true + }, + "saved_keywords_project_created_idx": { + "name": "saved_keywords_project_created_idx", + "columns": [ + "project_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "saved_keywords_project_id_projects_id_fk": { + "name": "saved_keywords_project_id_projects_id_fk", + "tableFrom": "saved_keywords", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invitation": { + "name": "invitation", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "invitation_organizationId_idx": { + "name": "invitation_organizationId_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + "email" + ], + "isUnique": false + } + }, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "member": { + "name": "member", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "member_organizationId_idx": { + "name": "member_organizationId_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "member_userId_idx": { + "name": "member_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organization": { + "name": "organization", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "organization_slug_uidx": { + "name": "organization_slug_uidx", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "analytics_opted_out": { + "name": "analytics_opted_out", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + "identifier" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 15f19a7..366a4e4 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -71,6 +71,13 @@ "when": 1776347182067, "tag": "0009_smart_kitty_pryde", "breakpoints": true + }, + { + "idx": 10, + "version": "6", + "when": 1777422740449, + "tag": "0010_high_liz_osborn", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/client/layout/AppShell.tsx b/src/client/layout/AppShell.tsx index c35aaf8..6c1b4c8 100644 --- a/src/client/layout/AppShell.tsx +++ b/src/client/layout/AppShell.tsx @@ -6,6 +6,7 @@ import { CircleHelp, CreditCard, Menu, + Settings, User, } from "lucide-react"; import { @@ -13,7 +14,6 @@ import { MissingSeoSetupModal, SeoApiStatusBanners, } from "@/client/layout/AppShellParts"; -import { ThemePreferenceMenuItems } from "@/client/components/ThemePreferenceMenuItems"; import { getProjectNavGroups } from "@/client/navigation/items"; import { signOutAndRedirect, useSession } from "@/lib/auth-client"; import { isHostedClientAuthMode } from "@/lib/auth-mode"; @@ -322,7 +322,9 @@ function AccountMenu({ mobileOnly = false }: { mobileOnly?: boolean }) { > {email ? (
  • - {email} + + {email} +
  • ) : null} {mobileOnly ? ( @@ -341,6 +343,12 @@ function AccountMenu({ mobileOnly = false }: { mobileOnly?: boolean }) { ) : null} +
  • + + + Settings + +
  • {isHostedMode && email ? (
  • ) : null} - diff --git a/src/client/lib/posthog.ts b/src/client/lib/posthog.ts index 63e9d09..bbc2b5a 100644 --- a/src/client/lib/posthog.ts +++ b/src/client/lib/posthog.ts @@ -7,6 +7,7 @@ type BrowserPostHogClient = typeof import("posthog-js").default; let browserPostHogClientPromise: Promise | null = null; let browserPostHogInitialized = false; +let analyticsCaptureEnabled = true; function getBrowserPostHogClient(): Promise { if (typeof window === "undefined" || !isHostedClientAuthMode()) { @@ -34,6 +35,11 @@ function getBrowserPostHogClient(): Promise { defaults: "2026-01-30", capture_exceptions: true, capture_pageview: "history_change", + respect_dnt: true, + session_recording: { + maskAllInputs: true, + maskTextSelector: "[data-ph-mask], .ph-mask", + }, sanitize_properties(properties, event) { if (event === "$pageview" || event === "$pageleave") { const url: unknown = properties["$current_url"]; @@ -63,10 +69,6 @@ function getBrowserPostHogClient(): Promise { return browserPostHogClientPromise; } -export function initPostHog() { - void getBrowserPostHogClient(); -} - function withPostHogClient(fn: (client: BrowserPostHogClient) => void) { void getBrowserPostHogClient().then((client) => { if (!client) return; @@ -78,10 +80,23 @@ function withPostHogClient(fn: (client: BrowserPostHogClient) => void) { }); } +function withExistingPostHogClient(fn: (client: BrowserPostHogClient) => void) { + if (!browserPostHogClientPromise) return; + void browserPostHogClientPromise.then((client) => { + if (!client) return; + try { + fn(client); + } catch (e) { + console.error("posthog operation failed", e); + } + }); +} + export function captureClientEvent( event: string, properties?: Record, ) { + if (!analyticsCaptureEnabled) return; withPostHogClient((client) => client.capture(event, properties)); } @@ -89,6 +104,7 @@ export function identifyAnalyticsUser(args: { userId: string; organizationId: string | null; }) { + if (!analyticsCaptureEnabled) return; withPostHogClient((client) => { client.identify(args.userId); if (args.organizationId) { @@ -98,13 +114,39 @@ export function identifyAnalyticsUser(args: { } export function resetAnalyticsUser() { - withPostHogClient((client) => client.reset()); + withExistingPostHogClient((client) => { + client.stopSessionRecording(); + client.reset(); + }); +} + +export function stopAnalyticsCapture() { + analyticsCaptureEnabled = false; + if (!browserPostHogInitialized || !browserPostHogClientPromise) return; + void browserPostHogClientPromise.then((client) => { + if (!client) return; + try { + client.stopSessionRecording(); + client.opt_out_capturing(); + } catch (e) { + console.error("posthog opt-out failed", e); + } + }); +} + +export function startAnalyticsCapture() { + analyticsCaptureEnabled = true; + withPostHogClient((client) => { + client.opt_in_capturing(); + client.startSessionRecording(); + }); } export function captureClientError( error: unknown, properties: Record = {}, ) { + if (!analyticsCaptureEnabled) return; withPostHogClient((client) => client.captureException(error, { source: "client", diff --git a/src/db/better-auth-schema.ts b/src/db/better-auth-schema.ts index 3d48147..60e8453 100644 --- a/src/db/better-auth-schema.ts +++ b/src/db/better-auth-schema.ts @@ -22,6 +22,7 @@ export const user = sqliteTable("user", { .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) .$onUpdate(() => /* @__PURE__ */ new Date()) .notNull(), + analyticsOptedOut: integer("analytics_opted_out", { mode: "boolean" }), }); export const session = sqliteTable( diff --git a/src/lib/auth-client.ts b/src/lib/auth-client.ts index 50b58e9..f30dfb1 100644 --- a/src/lib/auth-client.ts +++ b/src/lib/auth-client.ts @@ -1,11 +1,18 @@ import { createAuthClient } from "better-auth/react"; -import { organizationClient } from "better-auth/client/plugins"; +import { + inferAdditionalFields, + organizationClient, +} from "better-auth/client/plugins"; import { captureClientEvent, resetAnalyticsUser } from "@/client/lib/posthog"; +import { userAdditionalFields } from "@/lib/auth-options"; import { getSignInHrefForLocation } from "@/lib/auth-redirect"; export const authClient = createAuthClient({ baseURL: typeof window !== "undefined" ? window.location.origin : "", - plugins: [organizationClient()], + plugins: [ + organizationClient(), + inferAdditionalFields({ user: userAdditionalFields }), + ], }); export const { useSession } = authClient; diff --git a/src/lib/auth-options.ts b/src/lib/auth-options.ts index 898f256..9debbd4 100644 --- a/src/lib/auth-options.ts +++ b/src/lib/auth-options.ts @@ -1,6 +1,15 @@ export const HOSTED_PASSWORD_MIN_LENGTH = 8; export const HOSTED_PASSWORD_MAX_LENGTH = 128; +export const userAdditionalFields = { + analyticsOptedOut: { + type: "boolean" as const, + defaultValue: () => false, + required: false as const, + input: true as const, + }, +}; + export const baseAuthOptions = { emailAndPassword: { enabled: true, @@ -8,4 +17,7 @@ export const baseAuthOptions = { minPasswordLength: HOSTED_PASSWORD_MIN_LENGTH, maxPasswordLength: HOSTED_PASSWORD_MAX_LENGTH, }, + user: { + additionalFields: userAdditionalFields, + }, }; diff --git a/src/lib/auth.ts b/src/lib/auth.ts index eb2fc04..d60e42b 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -94,6 +94,8 @@ function getTrustedOrigins(baseUrl: string) { trustedOrigins.push( "http://open-seo.localhost:1355", "http://*.open-seo.localhost:1355", + "https://open-seo.localhost:1355", + "https://*.open-seo.localhost:1355", ); } diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 8097990..4d96081 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -21,6 +21,7 @@ import { Route as AuthenticatedSubscribeRouteImport } from './routes/_authentica import { Route as AuthSignUpRouteImport } from './routes/_auth.sign-up' import { Route as AuthSignInRouteImport } from './routes/_auth.sign-in' import { Route as AppSupportRouteImport } from './routes/_app/support' +import { Route as AppSettingsRouteImport } from './routes/_app/settings' import { Route as AppBillingRouteImport } from './routes/_app/billing' import { Route as ApiAutumnSplatRouteImport } from './routes/api/autumn/$' import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$' @@ -97,6 +98,11 @@ const AppSupportRoute = AppSupportRouteImport.update({ path: '/support', getParentRoute: () => AppRouteRoute, } as any) +const AppSettingsRoute = AppSettingsRouteImport.update({ + id: '/settings', + path: '/settings', + getParentRoute: () => AppRouteRoute, +} as any) const AppBillingRoute = AppBillingRouteImport.update({ id: '/billing', path: '/billing', @@ -208,6 +214,7 @@ export interface FileRoutesByFullPath { '/reset-password': typeof ResetPasswordRoute '/verify-email': typeof VerifyEmailRoute '/billing': typeof AppBillingRoute + '/settings': typeof AppSettingsRoute '/support': typeof AppSupportRoute '/sign-in': typeof AuthSignInRoute '/sign-up': typeof AuthSignUpRoute @@ -237,6 +244,7 @@ export interface FileRoutesByTo { '/reset-password': typeof ResetPasswordRoute '/verify-email': typeof VerifyEmailRoute '/billing': typeof AppBillingRoute + '/settings': typeof AppSettingsRoute '/support': typeof AppSupportRoute '/sign-in': typeof AuthSignInRoute '/sign-up': typeof AuthSignUpRoute @@ -267,6 +275,7 @@ export interface FileRoutesById { '/reset-password': typeof ResetPasswordRoute '/verify-email': typeof VerifyEmailRoute '/_app/billing': typeof AppBillingRoute + '/_app/settings': typeof AppSettingsRoute '/_app/support': typeof AppSupportRoute '/_auth/sign-in': typeof AuthSignInRoute '/_auth/sign-up': typeof AuthSignUpRoute @@ -299,6 +308,7 @@ export interface FileRouteTypes { | '/reset-password' | '/verify-email' | '/billing' + | '/settings' | '/support' | '/sign-in' | '/sign-up' @@ -328,6 +338,7 @@ export interface FileRouteTypes { | '/reset-password' | '/verify-email' | '/billing' + | '/settings' | '/support' | '/sign-in' | '/sign-up' @@ -357,6 +368,7 @@ export interface FileRouteTypes { | '/reset-password' | '/verify-email' | '/_app/billing' + | '/_app/settings' | '/_app/support' | '/_auth/sign-in' | '/_auth/sign-up' @@ -480,6 +492,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppSupportRouteImport parentRoute: typeof AppRouteRoute } + '/_app/settings': { + id: '/_app/settings' + path: '/settings' + fullPath: '/settings' + preLoaderRoute: typeof AppSettingsRouteImport + parentRoute: typeof AppRouteRoute + } '/_app/billing': { id: '/_app/billing' path: '/billing' @@ -618,6 +637,7 @@ declare module '@tanstack/react-router' { interface AppRouteRouteChildren { AppBillingRoute: typeof AppBillingRoute + AppSettingsRoute: typeof AppSettingsRoute AppSupportRoute: typeof AppSupportRoute AppIndexRoute: typeof AppIndexRoute AppHelpDataforseoApiKeyRoute: typeof AppHelpDataforseoApiKeyRoute @@ -625,6 +645,7 @@ interface AppRouteRouteChildren { const AppRouteRouteChildren: AppRouteRouteChildren = { AppBillingRoute: AppBillingRoute, + AppSettingsRoute: AppSettingsRoute, AppSupportRoute: AppSupportRoute, AppIndexRoute: AppIndexRoute, AppHelpDataforseoApiKeyRoute: AppHelpDataforseoApiKeyRoute, diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index cec6a05..c4d4bf5 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -14,8 +14,9 @@ import { DefaultCatchBoundary } from "@/client/components/DefaultCatchBoundary"; import { themePreferenceInitScript } from "@/client/lib/theme"; import { identifyAnalyticsUser, - initPostHog, resetAnalyticsUser, + startAnalyticsCapture, + stopAnalyticsCapture, } from "@/client/lib/posthog"; import { NotFound } from "@/client/components/NotFound"; import appCss from "@/client/styles/app.css?url"; @@ -85,6 +86,7 @@ function PostHogBootstrap() { const isHostedMode = isHostedClientAuthMode(); const { data: session, isPending: isSessionPending } = useSession(); const userId = session?.user?.id ?? null; + const optedOut = session?.user?.analyticsOptedOut === true; const organizationId = getActiveOrganizationId(session); const previousUserIdRef = React.useRef(null); @@ -93,16 +95,17 @@ function PostHogBootstrap() { return; } - initPostHog(); - - if (userId) { + if (userId && !optedOut) { + startAnalyticsCapture(); identifyAnalyticsUser({ userId, organizationId }); previousUserIdRef.current = userId; + } else if (userId && optedOut) { + stopAnalyticsCapture(); } else if (previousUserIdRef.current) { previousUserIdRef.current = null; resetAnalyticsUser(); } - }, [isHostedMode, isSessionPending, organizationId, userId]); + }, [isHostedMode, isSessionPending, optedOut, organizationId, userId]); return null; } diff --git a/src/routes/_app/settings.tsx b/src/routes/_app/settings.tsx new file mode 100644 index 0000000..5b05403 --- /dev/null +++ b/src/routes/_app/settings.tsx @@ -0,0 +1,119 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { Monitor, Moon, Sun } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { type ThemePreference, useThemePreference } from "@/client/lib/theme"; +import { authClient, useSession } from "@/lib/auth-client"; +import { isHostedClientAuthMode } from "@/lib/auth-mode"; + +export const Route = createFileRoute("/_app/settings")({ + component: SettingsPage, +}); + +const THEME_OPTIONS: { + value: ThemePreference; + label: string; + icon: typeof Sun; +}[] = [ + { value: "system", label: "System", icon: Monitor }, + { value: "light", label: "Light", icon: Sun }, + { value: "dark", label: "Dark", icon: Moon }, +]; + +function SettingsPage() { + const isHosted = isHostedClientAuthMode(); + const { themePreference, setThemePreference } = useThemePreference(); + const { data: session, isPending: isSessionPending } = useSession(); + const [isSaving, setIsSaving] = useState(false); + + const analyticsEnabled = session?.user?.analyticsOptedOut !== true; + + async function updateAnalyticsPreference(enabled: boolean) { + setIsSaving(true); + try { + const result = await authClient.updateUser({ + analyticsOptedOut: !enabled, + }); + if (result.error) { + toast.error("We couldn't update your analytics setting."); + } else { + toast.success(enabled ? "Analytics enabled" : "Analytics disabled"); + } + } catch { + toast.error("We couldn't update your analytics setting."); + } finally { + setIsSaving(false); + } + } + + return ( +
    +
    +

    Settings

    + +
    +

    + Appearance +

    +
    + Theme +
    + {THEME_OPTIONS.map((option) => { + const isActive = option.value === themePreference; + const Icon = option.icon; + + return ( + + ); + })} +
    +
    +
    + + {isHosted ? ( +
    +

    + Analytics +

    +
    +
    +

    Help improve OpenSEO

    +

    + Share analytics and usage data. +

    +
    + { + void updateAnalyticsPreference(event.currentTarget.checked); + }} + aria-label="Enable product analytics" + /> +
    +
    + ) : null} +
    +
    + ); +} diff --git a/src/routes/_authenticated.subscribe.tsx b/src/routes/_authenticated.subscribe.tsx index 2bfd259..89ae1fc 100644 --- a/src/routes/_authenticated.subscribe.tsx +++ b/src/routes/_authenticated.subscribe.tsx @@ -1,7 +1,7 @@ -import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { Link, createFileRoute, useNavigate } from "@tanstack/react-router"; import { AutumnProvider, useCustomer } from "autumn-js/react"; import { useEffect, useState } from "react"; -import { ArrowRight, User } from "lucide-react"; +import { ArrowRight, Settings, User } from "lucide-react"; import { ThemePreferenceMenuItems } from "@/client/components/ThemePreferenceMenuItems"; import { captureClientEvent } from "@/client/lib/posthog"; import { signOutAndRedirect, useSession } from "@/lib/auth-client"; @@ -235,7 +235,15 @@ function SubscribePageAccountMenu({ email }: { email: string | undefined }) { className="dropdown-content z-20 menu mt-3 min-w-56 rounded-box border border-base-300 bg-base-100 p-2 shadow-lg" >
  • - {email} + + {email} + +
  • +
  • + + + Settings +
  • diff --git a/web/content/legal/privacy.md b/web/content/legal/privacy.md index 47de050..ea0731e 100644 --- a/web/content/legal/privacy.md +++ b/web/content/legal/privacy.md @@ -3,7 +3,7 @@ title: Privacy Policy description: How OpenSEO collects, uses, and stores personal data. --- -_Last updated: April 1, 2026_ +_Last updated: April 28, 2026_ Every App, Inc PRIVACY POLICY @@ -21,7 +21,7 @@ I. INFORMATION WE COLLECT We collect “Non-Personal Information” and “Personal Information.” **Non-Personal Information** includes aggregate or de-identified usage information such as referring/exit pages and URLs, page visits, browser type, operating system, device type, approximate location derived from IP address, and similar analytics information. **Personal Information** includes information such as your name, email address, password, account and session information, organization information, billing or customer identifiers, and other information that you submit to us through the Site or Service. 1\. _Information collected via Technology_ -To receive marketing communications from us, you only need to submit your email address. To use the hosted version of the Service thereafter, you may need to submit further Personal Information, such as your name, email address, password, organization information, billing or customer identifiers, and other information you choose to provide through the Service. However, in an effort to improve the quality of the Service, we track information provided to us by your browser or by our software application when you view or use the Service, such as the website you came from (known as the “referring URL”), the pages you visit, the type of browser you use, the device from which you connected to the Service, your operating system, approximate location derived from IP address, the time and date of access, and other information related to how you use the Site or Service. +To receive marketing communications from us, you only need to submit your email address. To use the hosted version of the Service thereafter, you may need to submit further Personal Information, such as your name, email address, password, organization information, billing or customer identifiers, and other information you choose to provide through the Service. However, in an effort to improve the quality of the Service, we track information provided to us by your browser or by our software application when you view or use the Service, such as the website you came from (known as the “referring URL”), the pages you visit, the type of browser you use, the device from which you connected to the Service, your operating system, approximate location derived from IP address, the time and date of access, and other information related to how you use the Site or Service. In the authenticated hosted Service, PostHog may also collect session replay recordings to help us diagnose bugs and improve usability; form inputs and designated sensitive text are masked before recording. The public Site uses privacy-friendly analytics through Plausible Analytics, which is configured as cookieless analytics and proxied through our domain. In the hosted version of the Service, we may use cookies or similar technologies that are necessary to authenticate users, maintain secure sessions, and protect accounts. Where cookies are used in the hosted version of the Service, the Company may use both persistent and session cookies; persistent cookies remain on your computer after you close your session and until you delete them, while session cookies expire when you close your browser. @@ -33,7 +33,7 @@ The Site and the Service are not directed to anyone under the age of 13\. As sta II. HOW WE USE AND SHARE INFORMATION _Personal Information:_ -Except as otherwise stated in this Privacy Policy, we do not sell, trade, rent or otherwise share for marketing purposes your Personal Information with third parties without your consent. We do share Personal Information with vendors who are performing services for the Company, such as providers that help us send email communications, operate analytics, process billing, provide data requested through the Service, and host or support the infrastructure behind the Site and Service. These vendors may include Loops for email communications, Plausible for public-site analytics, PostHog for hosted analytics and error monitoring, Autumn and Stripe for hosted billing and payment processing, Cloudflare for hosting, storage, and access controls, and DataForSEO for data requested through the Service. Those vendors use your Personal Information only at our direction and in accordance with our Privacy Policy. +Except as otherwise stated in this Privacy Policy, we do not sell, trade, rent or otherwise share for marketing purposes your Personal Information with third parties without your consent. We do share Personal Information with vendors who are performing services for the Company, such as providers that help us send email communications, operate analytics, process billing, provide data requested through the Service, and host or support the infrastructure behind the Site and Service. These vendors may include Loops for email communications, Plausible for public-site analytics, PostHog for hosted analytics, session replay, and error monitoring, Autumn and Stripe for hosted billing and payment processing, Cloudflare for hosting, storage, and access controls, and DataForSEO for data requested through the Service. Those vendors use your Personal Information only at our direction and in accordance with our Privacy Policy. In general, the Personal Information you provide to us is used to help us communicate with you and operate the Service. For example, we use Personal Information to create and secure accounts, authenticate users, provide technical support, process billing, send administrative or transactional emails, contact users in response to questions, solicit feedback from users, and inform users about product updates and promotional offers. We may share Personal Information with outside parties if we have a good-faith belief that access, use, preservation or disclosure of the information is reasonably necessary to meet any applicable legal process or enforceable governmental request; to enforce applicable Terms of Service, including investigation of potential violations; address fraud, security or technical concerns; or to protect against harm to the rights, property, or safety of our users or the public as required or permitted by law. @@ -55,6 +55,6 @@ The Company reserves the right to change this policy and our Terms of Service at VII. CONTACT US If you have any questions regarding this Privacy Policy or the practices of this Site, please contact us by sending an email to ben@openseo.so. -Last Updated: This Privacy Policy was last updated on April 1, 2026. +Last Updated: This Privacy Policy was last updated on April 28, 2026. [image1]: data:image/png;base64,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