diff --git a/drizzle/0028_wallet_notices.sql b/drizzle/0028_wallet_notices.sql new file mode 100644 index 0000000..801a9e9 --- /dev/null +++ b/drizzle/0028_wallet_notices.sql @@ -0,0 +1,2 @@ +ALTER TABLE "wallet_connections" ADD COLUMN "balance_notice_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "wallet_connections" ADD COLUMN "reconnect_notice_at" timestamp with time zone; \ No newline at end of file diff --git a/drizzle/meta/0028_snapshot.json b/drizzle/meta/0028_snapshot.json new file mode 100644 index 0000000..1168ead --- /dev/null +++ b/drizzle/meta/0028_snapshot.json @@ -0,0 +1,3052 @@ +{ + "id": "83525f9a-f9cc-4e33-b706-e0113fd08c48", + "prevId": "36cdfaf8-ed98-4059-b9bb-605b65dbf9c8", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.post_readings": { + "name": "post_readings", + "schema": "", + "columns": { + "post_id": { + "name": "post_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "relationship": { + "name": "relationship", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "need_state": { + "name": "need_state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quote": { + "name": "quote", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reading_version": { + "name": "reading_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "read_at": { + "name": "read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "post_readings_post_id_reddit_posts_id_fk": { + "name": "post_readings_post_id_reddit_posts_id_fk", + "tableFrom": "post_readings", + "tableTo": "reddit_posts", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reddit_authors": { + "name": "reddit_authors", + "schema": "", + "columns": { + "username": { + "name": "username", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "karma": { + "name": "karma", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "account_created_at": { + "name": "account_created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "raw": { + "name": "raw", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "fetched_at": { + "name": "fetched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reddit_comments": { + "name": "reddit_comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "post_id": { + "name": "post_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "score": { + "name": "score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "permalink": { + "name": "permalink", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "fetched_at": { + "name": "fetched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "raw": { + "name": "raw", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "reddit_comments_post_id_reddit_posts_id_fk": { + "name": "reddit_comments_post_id_reddit_posts_id_fk", + "tableFrom": "reddit_comments", + "tableTo": "reddit_posts", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reddit_posts": { + "name": "reddit_posts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "subreddit": { + "name": "subreddit", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "score": { + "name": "score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "num_comments": { + "name": "num_comments", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_archived": { + "name": "is_archived", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "is_locked": { + "name": "is_locked", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "fetched_at": { + "name": "fetched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "body_observed_at": { + "name": "body_observed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "comments_observed_at": { + "name": "comments_observed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "comments_read_count": { + "name": "comments_read_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "raw": { + "name": "raw", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "reddit_posts_subreddit_created_at_idx": { + "name": "reddit_posts_subreddit_created_at_idx", + "columns": [ + { + "expression": "subreddit", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.search_run_posts": { + "name": "search_run_posts", + "schema": "", + "columns": { + "search_run_id": { + "name": "search_run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "post_id": { + "name": "post_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "search_run_posts_search_run_id_search_runs_id_fk": { + "name": "search_run_posts_search_run_id_search_runs_id_fk", + "tableFrom": "search_run_posts", + "tableTo": "search_runs", + "columnsFrom": [ + "search_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "search_run_posts_post_id_reddit_posts_id_fk": { + "name": "search_run_posts_post_id_reddit_posts_id_fk", + "tableFrom": "search_run_posts", + "tableTo": "reddit_posts", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "search_run_posts_search_run_id_post_id_pk": { + "name": "search_run_posts_search_run_id_post_id_pk", + "columns": [ + "search_run_id", + "post_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.search_runs": { + "name": "search_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sku": { + "name": "sku", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_query": { + "name": "normalized_query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sort": { + "name": "sort", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timeframe": { + "name": "timeframe", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "variant": { + "name": "variant", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "next_cursor": { + "name": "next_cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fetched_at": { + "name": "fetched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "numeric(12, 6)", + "primaryKey": false, + "notNull": false + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "funded_by": { + "name": "funded_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "search_runs_kind_query_fetched_at_idx": { + "name": "search_runs_kind_query_fetched_at_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "normalized_query", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "fetched_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.serp_results": { + "name": "serp_results", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "search_run_id": { + "name": "search_run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "snippet": { + "name": "snippet", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "serp_results_search_run_id_search_runs_id_fk": { + "name": "serp_results_search_run_id_search_runs_id_fk", + "tableFrom": "serp_results", + "tableTo": "search_runs", + "columnsFrom": [ + "search_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subreddits": { + "name": "subreddits", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subscribers": { + "name": "subscribers", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "rules_text": { + "name": "rules_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "promo_policy": { + "name": "promo_policy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fetched_at": { + "name": "fetched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.alerts": { + "name": "alerts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cadence": { + "name": "cadence", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_sent_at": { + "name": "last_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "alerts_project_id_projects_id_fk": { + "name": "alerts_project_id_projects_id_fk", + "tableFrom": "alerts", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "api_keys_user_id_users_id_fk": { + "name": "api_keys_user_id_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_keys_hash_unique": { + "name": "api_keys_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.candidate_sources": { + "name": "candidate_sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "post_id": { + "name": "post_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_key": { + "name": "source_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "candidate_sources_project_post_source_idx": { + "name": "candidate_sources_project_post_source_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "candidate_sources_project_id_projects_id_fk": { + "name": "candidate_sources_project_id_projects_id_fk", + "tableFrom": "candidate_sources", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "candidate_sources_post_id_reddit_posts_id_fk": { + "name": "candidate_sources_post_id_reddit_posts_id_fk", + "tableFrom": "candidate_sources", + "tableTo": "reddit_posts", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.discovery_evidence": { + "name": "discovery_evidence", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "post_id": { + "name": "post_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "canonical_url": { + "name": "canonical_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subreddit": { + "name": "subreddit", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "query": { + "name": "query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "family": { + "name": "family", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destination": { + "name": "destination", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "snippet": { + "name": "snippet", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "relevance": { + "name": "relevance", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "observed_at": { + "name": "observed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "discovery_evidence_project_post_query_idx": { + "name": "discovery_evidence_project_post_query_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "query", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "discovery_evidence_project_subreddit_idx": { + "name": "discovery_evidence_project_subreddit_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subreddit", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "discovery_evidence_project_id_projects_id_fk": { + "name": "discovery_evidence_project_id_projects_id_fk", + "tableFrom": "discovery_evidence", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jobs": { + "name": "jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "run_at": { + "name": "run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "progress": { + "name": "progress", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "jobs_project_id_projects_id_fk": { + "name": "jobs_project_id_projects_id_fk", + "tableFrom": "jobs", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lead_evaluations": { + "name": "lead_evaluations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "post_id": { + "name": "post_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "comment_id": { + "name": "comment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decision": { + "name": "decision", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "relationship": { + "name": "relationship", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "need_state": { + "name": "need_state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "fit": { + "name": "fit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "intent": { + "name": "intent", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "engagement": { + "name": "engagement", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "score": { + "name": "score", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reason_codes": { + "name": "reason_codes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "requirements": { + "name": "requirements", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "answer_coverage": { + "name": "answer_coverage", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "evidence_quote": { + "name": "evidence_quote", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile_version": { + "name": "profile_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scorer_version": { + "name": "scorer_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "judged_at": { + "name": "judged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "lead_evaluations_project_post_idx": { + "name": "lead_evaluations_project_post_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"lead_evaluations\".\"comment_id\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "lead_evaluations_project_comment_idx": { + "name": "lead_evaluations_project_comment_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "comment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"lead_evaluations\".\"comment_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "lead_evaluations_project_decision_idx": { + "name": "lead_evaluations_project_decision_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "decision", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "lead_evaluations_project_id_projects_id_fk": { + "name": "lead_evaluations_project_id_projects_id_fk", + "tableFrom": "lead_evaluations", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "lead_evaluations_post_id_reddit_posts_id_fk": { + "name": "lead_evaluations_post_id_reddit_posts_id_fk", + "tableFrom": "lead_evaluations", + "tableTo": "reddit_posts", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "lead_evaluations_comment_id_reddit_comments_id_fk": { + "name": "lead_evaluations_comment_id_reddit_comments_id_fk", + "tableFrom": "lead_evaluations", + "tableTo": "reddit_comments", + "columnsFrom": [ + "comment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.leads": { + "name": "leads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "post_id": { + "name": "post_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "comment_id": { + "name": "comment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "score": { + "name": "score", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "fit": { + "name": "fit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "intent": { + "name": "intent", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "engagement": { + "name": "engagement", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "stage": { + "name": "stage", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "matched_phrase": { + "name": "matched_phrase", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'buyer'" + }, + "seller_side": { + "name": "seller_side", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'new'" + }, + "not_fit_reason": { + "name": "not_fit_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thread_read_count": { + "name": "thread_read_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "scored_at": { + "name": "scored_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "found_at": { + "name": "found_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "leads_project_score_idx": { + "name": "leads_project_score_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "score", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leads_project_kind_idx": { + "name": "leads_project_kind_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leads_project_post_idx": { + "name": "leads_project_post_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"leads\".\"comment_id\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "leads_project_comment_idx": { + "name": "leads_project_comment_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "comment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"leads\".\"comment_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "leads_project_id_projects_id_fk": { + "name": "leads_project_id_projects_id_fk", + "tableFrom": "leads", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "leads_post_id_reddit_posts_id_fk": { + "name": "leads_post_id_reddit_posts_id_fk", + "tableFrom": "leads", + "tableTo": "reddit_posts", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "leads_comment_id_reddit_comments_id_fk": { + "name": "leads_comment_id_reddit_comments_id_fk", + "tableFrom": "leads", + "tableTo": "reddit_comments", + "columnsFrom": [ + "comment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.llm_usage": { + "name": "llm_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "purpose": { + "name": "purpose", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cost_usd": { + "name": "cost_usd", + "type": "numeric(12, 6)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "items_asked": { + "name": "items_asked", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "items_answered": { + "name": "items_answered", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "finish_reason": { + "name": "finish_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema_failed": { + "name": "schema_failed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "reasoning_tokens": { + "name": "reasoning_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "at": { + "name": "at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "llm_usage_at_idx": { + "name": "llm_usage_at_idx", + "columns": [ + { + "expression": "at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "llm_usage_project_purpose_at_idx": { + "name": "llm_usage_project_purpose_at_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "purpose", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "llm_usage_project_id_projects_id_fk": { + "name": "llm_usage_project_id_projects_id_fk", + "tableFrom": "llm_usage", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pain_themes": { + "name": "pain_themes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lead_ids": { + "name": "lead_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "generated_at": { + "name": "generated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "pain_themes_project_id_projects_id_fk": { + "name": "pain_themes_project_id_projects_id_fk", + "tableFrom": "pain_themes", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_competitors": { + "name": "project_competitors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'llm'" + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "evidence": { + "name": "evidence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_covered_at": { + "name": "last_covered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "fresh_candidates": { + "name": "fresh_candidates", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "fresh_leads": { + "name": "fresh_leads", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "project_competitors_project_name_idx": { + "name": "project_competitors_project_name_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_competitors_project_id_projects_id_fk": { + "name": "project_competitors_project_id_projects_id_fk", + "tableFrom": "project_competitors", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_keywords": { + "name": "project_keywords", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "keyword": { + "name": "keyword", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'llm'" + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "evidence": { + "name": "evidence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_covered_at": { + "name": "last_covered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "fresh_candidates": { + "name": "fresh_candidates", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "fresh_leads": { + "name": "fresh_leads", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "project_keywords_project_keyword_idx": { + "name": "project_keywords_project_keyword_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "keyword", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_keywords_project_id_projects_id_fk": { + "name": "project_keywords_project_id_projects_id_fk", + "tableFrom": "project_keywords", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_subreddits": { + "name": "project_subreddits", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'llm'" + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "evidence": { + "name": "evidence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_covered_at": { + "name": "last_covered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "fresh_candidates": { + "name": "fresh_candidates", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "fresh_leads": { + "name": "fresh_leads", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "project_subreddits_project_name_idx": { + "name": "project_subreddits_project_name_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_subreddits_project_id_projects_id_fk": { + "name": "project_subreddits_project_id_projects_id_fk", + "tableFrom": "project_subreddits", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pain": { + "name": "pain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "solution": { + "name": "solution", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_users": { + "name": "target_users", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "geography": { + "name": "geography", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "budget_fit": { + "name": "budget_fit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "score_threshold": { + "name": "score_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "profile_version": { + "name": "profile_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "discovery_version": { + "name": "discovery_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "capabilities": { + "name": "capabilities", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "exclusions": { + "name": "exclusions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "not_buyers": { + "name": "not_buyers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "destinations": { + "name": "destinations", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "problem_phrasings": { + "name": "problem_phrasings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "tier_snapshot": { + "name": "tier_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "discovered_at": { + "name": "discovered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "projects_user_id_users_id_fk": { + "name": "projects_user_id_users_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.seo_opportunities": { + "name": "seo_opportunities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "keyword": { + "name": "keyword", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "post_id": { + "name": "post_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "competitor_present": { + "name": "competitor_present", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "refreshed_at": { + "name": "refreshed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "seo_opportunities_project_id_projects_id_fk": { + "name": "seo_opportunities_project_id_projects_id_fk", + "tableFrom": "seo_opportunities", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "seo_opportunities_post_id_reddit_posts_id_fk": { + "name": "seo_opportunities_post_id_reddit_posts_id_fk", + "tableFrom": "seo_opportunities", + "tableTo": "reddit_posts", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_ledger": { + "name": "usage_ledger", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sku": { + "name": "sku", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cost_usd": { + "name": "cost_usd", + "type": "numeric(12, 6)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "search_run_id": { + "name": "search_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "funded_by": { + "name": "funded_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'house'" + }, + "reused": { + "name": "reused", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "at": { + "name": "at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_ledger_project_at_idx": { + "name": "usage_ledger_project_at_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_ledger_project_id_projects_id_fk": { + "name": "usage_ledger_project_id_projects_id_fk", + "tableFrom": "usage_ledger", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_ledger_search_run_id_search_runs_id_fk": { + "name": "usage_ledger_search_run_id_search_runs_id_fk", + "tableFrom": "usage_ledger", + "tableTo": "search_runs", + "columnsFrom": [ + "search_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_actions": { + "name": "user_actions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "at": { + "name": "at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_actions_user_action_at_idx": { + "name": "user_actions_user_action_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_actions_user_id_users_id_fk": { + "name": "user_actions_user_id_users_id_fk", + "tableFrom": "user_actions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "clerk_user_id": { + "name": "clerk_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_clerk_user_id_unique": { + "name": "users_clerk_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "clerk_user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet_connections": { + "name": "wallet_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cap_usd": { + "name": "cap_usd", + "type": "numeric(12, 6)", + "primaryKey": false, + "notNull": false + }, + "cap_period": { + "name": "cap_period", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "balance_notice_at": { + "name": "balance_notice_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "reconnect_notice_at": { + "name": "reconnect_notice_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "wallet_connections_user_id_users_id_fk": { + "name": "wallet_connections_user_id_users_id_fk", + "tableFrom": "wallet_connections", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "wallet_connections_user_id_unique": { + "name": "wallet_connections_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_request_counts": { + "name": "api_request_counts", + "schema": "", + "columns": { + "key_id": { + "name": "key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "day": { + "name": "day", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "api_request_counts_key_id_api_keys_id_fk": { + "name": "api_request_counts_key_id_api_keys_id_fk", + "tableFrom": "api_request_counts", + "tableTo": "api_keys", + "columnsFrom": [ + "key_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "api_request_counts_key_id_day_pk": { + "name": "api_request_counts_key_id_day_pk", + "columns": [ + "key_id", + "day" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.competitor_mentions": { + "name": "competitor_mentions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "competitor": { + "name": "competitor", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "post_id": { + "name": "post_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "comment_id": { + "name": "comment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sentiment": { + "name": "sentiment", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quote": { + "name": "quote", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "found_at": { + "name": "found_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "competitor_mentions_project_found_at_idx": { + "name": "competitor_mentions_project_found_at_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "found_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "competitor_mentions_project_competitor_post_idx": { + "name": "competitor_mentions_project_competitor_post_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "competitor", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "comment_id is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "competitor_mentions_project_competitor_comment_idx": { + "name": "competitor_mentions_project_competitor_comment_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "competitor", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "comment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "comment_id is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "competitor_mentions_project_id_projects_id_fk": { + "name": "competitor_mentions_project_id_projects_id_fk", + "tableFrom": "competitor_mentions", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "competitor_mentions_post_id_reddit_posts_id_fk": { + "name": "competitor_mentions_post_id_reddit_posts_id_fk", + "tableFrom": "competitor_mentions", + "tableTo": "reddit_posts", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "competitor_mentions_comment_id_reddit_comments_id_fk": { + "name": "competitor_mentions_comment_id_reddit_comments_id_fk", + "tableFrom": "competitor_mentions", + "tableTo": "reddit_comments", + "columnsFrom": [ + "comment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 023a50c..4d2715a 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -197,6 +197,13 @@ "when": 1789918247629, "tag": "0027_lead_found_at", "breakpoints": true + }, + { + "idx": 28, + "version": "7", + "when": 1789919602662, + "tag": "0028_wallet_notices", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/schema/tenant.ts b/src/db/schema/tenant.ts index 2ae3ba7..a23d4b2 100644 --- a/src/db/schema/tenant.ts +++ b/src/db/schema/tenant.ts @@ -45,6 +45,13 @@ export const walletConnections = pgTable("wallet_connections", { scope: text("scope"), capUsd: numeric("cap_usd", { precision: 12, scale: 6 }), capPeriod: text("cap_period"), + /** + * When the owner was last emailed that the wallet is empty, or that the + * connection was revoked. A failing job asks every hour; the person is told + * once, and again only if it is still so days later or after a reconnect. + */ + balanceNoticeAt: timestamp("balance_notice_at", { withTimezone: true }), + reconnectNoticeAt: timestamp("reconnect_notice_at", { withTimezone: true }), connectedAt: timestamp("connected_at", { withTimezone: true }).notNull().defaultNow(), }); diff --git a/src/jobs/runner.ts b/src/jobs/runner.ts index 26e4e43..5ce1daf 100644 --- a/src/jobs/runner.ts +++ b/src/jobs/runner.ts @@ -1,6 +1,7 @@ import { and, asc, eq, inArray, isNull, lt, lte, notInArray, or, sql } from "drizzle-orm"; import { db } from "@/db"; import { jobs } from "@/db/schema"; +import { noticeWalletTrouble } from "@/lib/alerts/walletNotice"; import { enqueueOnce } from "./enqueue"; import { HEARTBEAT_MS, LEASE_MS } from "./lease"; import { handlerFor, nextRunAt, type Job } from "./registry"; @@ -257,6 +258,8 @@ export async function runClaimedJob(job: Job): Promise { } if (await finish(job, lease, reasonFor(error))) { await requeueRecurring(job); + // Best effort: telling the owner must never change how the job ended. + await noticeWalletTrouble(job.projectId, error).catch(() => undefined); } } finally { stop(); diff --git a/src/lib/alerts/walletNotice.ts b/src/lib/alerts/walletNotice.ts new file mode 100644 index 0000000..dafe7a9 --- /dev/null +++ b/src/lib/alerts/walletNotice.ts @@ -0,0 +1,122 @@ +import { InsufficientBalanceError } from "@getanyapi/sdk"; +import { and, eq, isNull, lt, or } from "drizzle-orm"; +import { db } from "@/db"; +import { projects, users, walletConnections } from "@/db/schema"; +import { ANYAPI_URL, PRODUCT_NAME } from "@/lib/brand"; +import { config } from "@/lib/config"; +import { TokenRequestError } from "@/lib/oauth"; +import { sendEmail, type EmailMessage } from "./email"; +import { EMAIL_COLORS as C, EMAIL_FONT, escapeHtml } from "./tokens"; + +/** What stopped a wallet-funded job that only the wallet's owner can put right. */ +export type WalletTrouble = "balance" | "reconnect"; + +/** A failing job comes back every hour. The owner hears about it this often. */ +export const NOTICE_GAP_MS = 3 * 24 * 60 * 60 * 1000; + +/** Reads the whole cause chain, since a handler may wrap what the SDK threw. */ +export function walletTroubleOf(error: unknown): WalletTrouble | null { + const seen = new Set(); + let current: unknown = error; + while (typeof current === "object" && current !== null && !seen.has(current)) { + seen.add(current); + if (current instanceof InsufficientBalanceError) { + return "balance"; + } + if (current instanceof TokenRequestError && (current.status === 400 || current.status === 401)) { + return "reconnect"; + } + current = (current as { cause?: unknown }).cause; + } + return null; +} + +const COPY: Record = { + balance: { + subject: "Your AnyAPI wallet is out of credits", + lead: "has stopped scanning because the AnyAPI wallet that pays for it has run out of credits. Add credits and the next scheduled scan picks up where it left off; nothing else needs doing.", + cta: "Add credits at AnyAPI", + }, + reconnect: { + subject: "Reconnect your AnyAPI wallet", + lead: "has stopped scanning because its connection to your AnyAPI wallet is no longer valid. Reconnect it in settings and the next scheduled scan runs as usual.", + cta: "Reconnect in settings", + }, +}; + +export function renderWalletNotice( + trouble: WalletTrouble, + projectName: string, + to: string, +): EmailMessage { + const copy = COPY[trouble]; + const settingsUrl = `${config().APP_URL.replace(/\/$/, "")}/app/settings`; + const url = trouble === "balance" ? `${ANYAPI_URL}&utm_medium=email` : settingsUrl; + const sentence = `${projectName} ${copy.lead}`; + return { + to, + subject: copy.subject, + text: [`${sentence}`, `${copy.cta}: ${url}`, `${PRODUCT_NAME} settings: ${settingsUrl}`].join( + "\n\n", + ), + html: ` +${escapeHtml(copy.subject)} + + + + + +
${escapeHtml(copy.subject)}
${escapeHtml(sentence)}
${escapeHtml(copy.cta)}
`, + }; +} + +/** + * Emails the project's owner when the failure is their wallet's. The stamp is + * taken by a conditional update before the send, so two jobs failing together + * send one email, and it is handed back if the send itself fails. + */ +export async function noticeWalletTrouble( + projectId: string | null, + error: unknown, + now = new Date(), +): Promise { + const trouble = walletTroubleOf(error); + if (!trouble || !projectId) { + return false; + } + const [owner] = await db() + .select({ userId: users.id, email: users.email, projectName: projects.name }) + .from(projects) + .innerJoin(users, eq(users.id, projects.userId)) + .where(eq(projects.id, projectId)); + if (!owner?.email) { + return false; + } + const column = + trouble === "balance" ? walletConnections.balanceNoticeAt : walletConnections.reconnectNoticeAt; + const field = trouble === "balance" ? "balanceNoticeAt" : "reconnectNoticeAt"; + // No wallet row means the house paid, and that is ours to fix, not theirs. + const claimed = await db() + .update(walletConnections) + .set({ [field]: now }) + .where( + and( + eq(walletConnections.userId, owner.userId), + or(isNull(column), lt(column, new Date(now.getTime() - NOTICE_GAP_MS))), + ), + ) + .returning({ id: walletConnections.id }); + if (claimed.length === 0) { + return false; + } + try { + await sendEmail(renderWalletNotice(trouble, owner.projectName, owner.email)); + return true; + } catch (sendError) { + await db() + .update(walletConnections) + .set({ [field]: null }) + .where(eq(walletConnections.userId, owner.userId)); + throw sendError; + } +} diff --git a/src/lib/anyapi.ts b/src/lib/anyapi.ts index 2087bbe..14dddb8 100644 --- a/src/lib/anyapi.ts +++ b/src/lib/anyapi.ts @@ -158,6 +158,8 @@ export async function saveWalletTokens( accessToken: encryptSecret(tokens.access_token, key), accessTokenExpiresAt: new Date(Date.now() + tokens.expires_in * 1000), scope: tokens.scope, + balanceNoticeAt: null, + reconnectNoticeAt: null, connectedAt: new Date(), }; await db() diff --git a/src/lib/oauth.ts b/src/lib/oauth.ts index a1b7781..d70faad 100644 --- a/src/lib/oauth.ts +++ b/src/lib/oauth.ts @@ -47,6 +47,14 @@ export function authorizeUrl(state: string, challenge: string): string { return `${ANYAPI_BASE_URL.replace(/\/$/, "")}/oauth/authorize?${query.toString()}`; } +/** The token endpoint said no. A 400 or 401 on a refresh means the grant is gone. */ +export class TokenRequestError extends Error { + constructor(readonly status: number) { + super(`AnyAPI token request failed with status ${status}`); + this.name = "TokenRequestError"; + } +} + async function postToken(form: URLSearchParams): Promise { const response = await fetch(`${config().ANYAPI_BASE_URL.replace(/\/$/, "")}/oauth/token`, { method: "POST", @@ -54,7 +62,7 @@ async function postToken(form: URLSearchParams): Promise { body: form, }); if (!response.ok) { - throw new Error(`AnyAPI token request failed with status ${response.status}`); + throw new TokenRequestError(response.status); } return (await response.json()) as TokenResponse; } diff --git a/tests/walletNotice.test.ts b/tests/walletNotice.test.ts new file mode 100644 index 0000000..5c679f1 --- /dev/null +++ b/tests/walletNotice.test.ts @@ -0,0 +1,95 @@ +import { randomUUID } from "node:crypto"; +import { InsufficientBalanceError } from "@getanyapi/sdk"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { TokenRequestError } from "@/lib/oauth"; + +const sendEmail = vi.fn<(mail: { to: string; subject: string }) => Promise>(async () => undefined); +vi.mock("@/lib/alerts/email", () => ({ sendEmail })); + +const broke = () => new InsufficientBalanceError("insufficient balance", 402, undefined, "insufficient_balance"); + +describe("which failures are the wallet owner's to fix", () => { + it("reads an empty wallet and a revoked grant through a wrapped error", async () => { + const { walletTroubleOf } = await import("@/lib/alerts/walletNotice"); + expect(walletTroubleOf(new Error("scan failed", { cause: broke() }))).toBe("balance"); + expect(walletTroubleOf(new TokenRequestError(400))).toBe("reconnect"); + expect(walletTroubleOf(new TokenRequestError(503))).toBeNull(); + expect(walletTroubleOf(new Error("The language model did not answer"))).toBeNull(); + }); +}); + +describe.skipIf(!process.env.DATABASE_URL)("emailing the owner of an empty wallet", () => { + beforeEach(() => sendEmail.mockClear()); + + async function fixture(withWallet: boolean) { + process.env.APP_ENCRYPTION_KEY ??= Buffer.alloc(32).toString("base64"); + const { db } = await import("@/db"); + const { projects, users } = await import("@/db/schema"); + const { saveWalletTokens } = await import("@/lib/anyapi"); + const [user] = await db() + .insert(users) + .values({ clerkUserId: `test_${randomUUID()}`, email: "owner@example.com" }) + .returning(); + const [project] = await db() + .insert(projects) + .values({ userId: user.id, name: "Formcraft" }) + .returning(); + if (withWallet) { + await saveWalletTokens(user.id, { + access_token: "a", + refresh_token: "r", + expires_in: 3600, + scope: "run", + }); + } + return { db, users, user, project, saveWalletTokens }; + } + + it("tells them once, again after the gap, and again after a reconnect", async () => { + const { noticeWalletTrouble, NOTICE_GAP_MS } = await import("@/lib/alerts/walletNotice"); + const { eq } = await import("drizzle-orm"); + const { db, users, user, project, saveWalletTokens } = await fixture(true); + try { + const now = new Date(); + const both = await Promise.all([ + noticeWalletTrouble(project.id, broke(), now), + noticeWalletTrouble(project.id, broke(), now), + ]); + expect(both.filter(Boolean)).toHaveLength(1); + expect(await noticeWalletTrouble(project.id, broke(), new Date(now.getTime() + 3_600_000))).toBe(false); + expect(sendEmail).toHaveBeenCalledTimes(1); + expect(sendEmail.mock.calls[0][0]).toMatchObject({ + to: "owner@example.com", + subject: "Your AnyAPI wallet is out of credits", + }); + + expect( + await noticeWalletTrouble(project.id, broke(), new Date(now.getTime() + NOTICE_GAP_MS + 1000)), + ).toBe(true); + + await saveWalletTokens(user.id, { access_token: "a", refresh_token: "r", expires_in: 3600, scope: "run" }); + expect(await noticeWalletTrouble(project.id, broke(), now)).toBe(true); + expect(sendEmail).toHaveBeenCalledTimes(3); + } finally { + await db().delete(users).where(eq(users.id, user.id)); + } + }); + + it("says nothing when the house paid, and hands the stamp back when the send fails", async () => { + const { noticeWalletTrouble } = await import("@/lib/alerts/walletNotice"); + const { eq } = await import("drizzle-orm"); + const free = await fixture(false); + const paid = await fixture(true); + try { + expect(await noticeWalletTrouble(free.project.id, broke())).toBe(false); + expect(sendEmail).not.toHaveBeenCalled(); + + sendEmail.mockRejectedValueOnce(new Error("smtp down")); + await expect(noticeWalletTrouble(paid.project.id, broke())).rejects.toThrow("smtp down"); + expect(await noticeWalletTrouble(paid.project.id, broke())).toBe(true); + } finally { + await free.db().delete(free.users).where(eq(free.users.id, free.user.id)); + await paid.db().delete(paid.users).where(eq(paid.users.id, paid.user.id)); + } + }); +});