diff --git a/drizzle/0033_wooden_the_captain.sql b/drizzle/0033_wooden_the_captain.sql new file mode 100644 index 0000000..261540d --- /dev/null +++ b/drizzle/0033_wooden_the_captain.sql @@ -0,0 +1,3 @@ +CREATE TYPE "public"."file_type" AS ENUM('pdf', 'csv');--> statement-breakpoint +ALTER TYPE "public"."upload_status" ADD VALUE 'waiting_for_csv_answers';--> statement-breakpoint +ALTER TABLE "upload" ADD COLUMN "file_type" "file_type" DEFAULT 'pdf' NOT NULL; \ No newline at end of file diff --git a/drizzle/0034_true_nekra.sql b/drizzle/0034_true_nekra.sql new file mode 100644 index 0000000..1d79467 --- /dev/null +++ b/drizzle/0034_true_nekra.sql @@ -0,0 +1 @@ +ALTER TABLE "upload" RENAME COLUMN "pdf_deleted" TO "file_deleted"; \ No newline at end of file diff --git a/drizzle/meta/0033_snapshot.json b/drizzle/meta/0033_snapshot.json new file mode 100644 index 0000000..7425321 --- /dev/null +++ b/drizzle/meta/0033_snapshot.json @@ -0,0 +1,1518 @@ +{ + "id": "73cca70d-f2fb-42e5-a4bf-e35d9e762d02", + "prevId": "2c388760-7bb9-4c0b-8ea1-28f0547f8ce8", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "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": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.budget": { + "name": "budget", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_amount": { + "name": "target_amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "budget_tenant_id_idx": { + "name": "budget_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "budget_deleted_idx": { + "name": "budget_deleted_idx", + "columns": [ + { + "expression": "deleted", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "budget_tenant_id_user_tenant_tenant_id_fk": { + "name": "budget_tenant_id_user_tenant_tenant_id_fk", + "tableFrom": "budget", + "tableTo": "user_tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["tenant_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.budget_category": { + "name": "budget_category", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "budget_id": { + "name": "budget_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "category_id": { + "name": "category_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "budget_category_budget_id_idx": { + "name": "budget_category_budget_id_idx", + "columns": [ + { + "expression": "budget_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "budget_category_category_id_idx": { + "name": "budget_category_category_id_idx", + "columns": [ + { + "expression": "category_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "budget_category_unique_idx": { + "name": "budget_category_unique_idx", + "columns": [ + { + "expression": "budget_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "category_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "budget_category_budget_id_budget_id_fk": { + "name": "budget_category_budget_id_budget_id_fk", + "tableFrom": "budget_category", + "tableTo": "budget", + "columnsFrom": ["budget_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "budget_category_category_id_category_id_fk": { + "name": "budget_category_category_id_category_id_fk", + "tableFrom": "budget_category", + "tableTo": "category", + "columnsFrom": ["category_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.categorization_rule": { + "name": "categorization_rule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "logic_operator": { + "name": "logic_operator", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'and'" + }, + "conditions": { + "name": "conditions", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "actions": { + "name": "actions", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "categorization_rule_tenant_id_idx": { + "name": "categorization_rule_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "categorization_rule_priority_idx": { + "name": "categorization_rule_priority_idx", + "columns": [ + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "categorization_rule_deleted_idx": { + "name": "categorization_rule_deleted_idx", + "columns": [ + { + "expression": "deleted", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "categorization_rule_tenant_id_user_tenant_tenant_id_fk": { + "name": "categorization_rule_tenant_id_user_tenant_tenant_id_fk", + "tableFrom": "categorization_rule", + "tableTo": "user_tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["tenant_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.category": { + "name": "category", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "category_tenant_id_idx": { + "name": "category_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "btree", + "with": {} + }, + "category_deleted_idx": { + "name": "category_deleted_idx", + "columns": [ + { + "expression": "deleted", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "category_tenant_id_user_tenant_tenant_id_fk": { + "name": "category_tenant_id_user_tenant_tenant_id_fk", + "tableFrom": "category", + "tableTo": "user_tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["tenant_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "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": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.transaction": { + "name": "transaction", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "upload_id": { + "name": "upload_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "category_id": { + "name": "category_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "merchant_name": { + "name": "merchant_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "confidence": { + "name": "confidence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "transaction_tenant_id_idx": { + "name": "transaction_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "btree", + "with": {} + }, + "transaction_upload_id_idx": { + "name": "transaction_upload_id_idx", + "columns": [ + { + "expression": "upload_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "btree", + "with": {} + }, + "transaction_category_id_idx": { + "name": "transaction_category_id_idx", + "columns": [ + { + "expression": "category_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "btree", + "with": {} + }, + "transaction_confidence_idx": { + "name": "transaction_confidence_idx", + "columns": [ + { + "expression": "confidence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "btree", + "with": {} + }, + "transaction_date_idx": { + "name": "transaction_date_idx", + "columns": [ + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "btree", + "with": {} + }, + "transaction_deleted_idx": { + "name": "transaction_deleted_idx", + "columns": [ + { + "expression": "deleted", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "btree", + "with": {} + }, + "transaction_merchant_name_idx": { + "name": "transaction_merchant_name_idx", + "columns": [ + { + "expression": "merchant_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "btree", + "with": {} + }, + "transaction_fingerprint_idx": { + "name": "transaction_fingerprint_idx", + "columns": [ + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "transaction_upload_id_upload_id_fk": { + "name": "transaction_upload_id_upload_id_fk", + "tableFrom": "transaction", + "tableTo": "upload", + "columnsFrom": ["upload_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "transaction_tenant_id_user_tenant_tenant_id_fk": { + "name": "transaction_tenant_id_user_tenant_tenant_id_fk", + "tableFrom": "transaction", + "tableTo": "user_tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["tenant_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "transaction_category_id_category_id_fk": { + "name": "transaction_category_id_category_id_fk", + "tableFrom": "transaction", + "tableTo": "category", + "columnsFrom": ["category_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.two_factor": { + "name": "two_factor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backup_codes": { + "name": "backup_codes", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "two_factor_user_id_user_id_fk": { + "name": "two_factor_user_id_user_id_fk", + "tableFrom": "two_factor", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.upload": { + "name": "upload", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "file_type": { + "name": "file_type", + "type": "file_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pdf'" + }, + "status": { + "name": "status", + "type": "upload_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "encrypted_password": { + "name": "encrypted_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failed_reason": { + "name": "failed_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "page_count": { + "name": "page_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "pdf_deleted": { + "name": "pdf_deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "retry_count": { + "name": "retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "upload_tenant_id_idx": { + "name": "upload_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "btree", + "with": {} + }, + "upload_deleted_idx": { + "name": "upload_deleted_idx", + "columns": [ + { + "expression": "deleted", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "upload_tenant_id_user_tenant_tenant_id_fk": { + "name": "upload_tenant_id_user_tenant_tenant_id_fk", + "tableFrom": "upload", + "tableTo": "user_tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["tenant_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "two_factor_enabled": { + "name": "two_factor_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_profile": { + "name": "user_profile", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "work_type": { + "name": "work_type", + "type": "work_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "primary_use": { + "name": "primary_use", + "type": "primary_use", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "industry": { + "name": "industry", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "onboarding_completed": { + "name": "onboarding_completed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_profile_user_id_user_id_fk": { + "name": "user_profile_user_id_user_id_fk", + "tableFrom": "user_profile", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_profile_user_id_unique": { + "name": "user_profile_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_tenant": { + "name": "user_tenant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id_hash": { + "name": "user_id_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id_encrypted": { + "name": "user_id_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dek_encrypted": { + "name": "dek_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_tenant_tenant_id_idx": { + "name": "user_tenant_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_tenant_user_id_hash_idx": { + "name": "user_tenant_user_id_hash_idx", + "columns": [ + { + "expression": "user_id_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_tenant_tenant_id_unique": { + "name": "user_tenant_tenant_id_unique", + "nullsNotDistinct": false, + "columns": ["tenant_id"] + }, + "user_tenant_user_id_hash_unique": { + "name": "user_tenant_user_id_hash_unique", + "nullsNotDistinct": false, + "columns": ["user_id_hash"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_access": { + "name": "granted_access", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "granted_at": { + "name": "granted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "waitlist_email_idx": { + "name": "waitlist_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "waitlist_granted_access_idx": { + "name": "waitlist_granted_access_idx", + "columns": [ + { + "expression": "granted_access", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.file_type": { + "name": "file_type", + "schema": "public", + "values": ["pdf", "csv"] + }, + "public.primary_use": { + "name": "primary_use", + "schema": "public", + "values": ["personal", "business", "both"] + }, + "public.upload_status": { + "name": "upload_status", + "schema": "public", + "values": [ + "queued", + "processing", + "completed", + "failed", + "cancelled", + "waiting_for_password", + "waiting_for_csv_answers" + ] + }, + "public.work_type": { + "name": "work_type", + "schema": "public", + "values": [ + "employed", + "self_employed", + "business_owner", + "student", + "retired", + "unemployed" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/drizzle/meta/0034_snapshot.json b/drizzle/meta/0034_snapshot.json new file mode 100644 index 0000000..2f93283 --- /dev/null +++ b/drizzle/meta/0034_snapshot.json @@ -0,0 +1,1518 @@ +{ + "id": "9866e5f1-8ca0-4f18-bb92-0f61ace05b23", + "prevId": "73cca70d-f2fb-42e5-a4bf-e35d9e762d02", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "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": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.budget": { + "name": "budget", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_amount": { + "name": "target_amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "budget_tenant_id_idx": { + "name": "budget_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "budget_deleted_idx": { + "name": "budget_deleted_idx", + "columns": [ + { + "expression": "deleted", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "budget_tenant_id_user_tenant_tenant_id_fk": { + "name": "budget_tenant_id_user_tenant_tenant_id_fk", + "tableFrom": "budget", + "tableTo": "user_tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["tenant_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.budget_category": { + "name": "budget_category", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "budget_id": { + "name": "budget_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "category_id": { + "name": "category_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "budget_category_budget_id_idx": { + "name": "budget_category_budget_id_idx", + "columns": [ + { + "expression": "budget_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "budget_category_category_id_idx": { + "name": "budget_category_category_id_idx", + "columns": [ + { + "expression": "category_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "budget_category_unique_idx": { + "name": "budget_category_unique_idx", + "columns": [ + { + "expression": "budget_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "category_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "budget_category_budget_id_budget_id_fk": { + "name": "budget_category_budget_id_budget_id_fk", + "tableFrom": "budget_category", + "tableTo": "budget", + "columnsFrom": ["budget_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "budget_category_category_id_category_id_fk": { + "name": "budget_category_category_id_category_id_fk", + "tableFrom": "budget_category", + "tableTo": "category", + "columnsFrom": ["category_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.categorization_rule": { + "name": "categorization_rule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "logic_operator": { + "name": "logic_operator", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'and'" + }, + "conditions": { + "name": "conditions", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "actions": { + "name": "actions", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "categorization_rule_tenant_id_idx": { + "name": "categorization_rule_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "categorization_rule_priority_idx": { + "name": "categorization_rule_priority_idx", + "columns": [ + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "categorization_rule_deleted_idx": { + "name": "categorization_rule_deleted_idx", + "columns": [ + { + "expression": "deleted", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "categorization_rule_tenant_id_user_tenant_tenant_id_fk": { + "name": "categorization_rule_tenant_id_user_tenant_tenant_id_fk", + "tableFrom": "categorization_rule", + "tableTo": "user_tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["tenant_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.category": { + "name": "category", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "category_tenant_id_idx": { + "name": "category_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "btree", + "with": {} + }, + "category_deleted_idx": { + "name": "category_deleted_idx", + "columns": [ + { + "expression": "deleted", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "category_tenant_id_user_tenant_tenant_id_fk": { + "name": "category_tenant_id_user_tenant_tenant_id_fk", + "tableFrom": "category", + "tableTo": "user_tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["tenant_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "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": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.transaction": { + "name": "transaction", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "upload_id": { + "name": "upload_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "category_id": { + "name": "category_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "merchant_name": { + "name": "merchant_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "confidence": { + "name": "confidence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "transaction_tenant_id_idx": { + "name": "transaction_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "btree", + "with": {} + }, + "transaction_upload_id_idx": { + "name": "transaction_upload_id_idx", + "columns": [ + { + "expression": "upload_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "btree", + "with": {} + }, + "transaction_category_id_idx": { + "name": "transaction_category_id_idx", + "columns": [ + { + "expression": "category_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "btree", + "with": {} + }, + "transaction_confidence_idx": { + "name": "transaction_confidence_idx", + "columns": [ + { + "expression": "confidence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "btree", + "with": {} + }, + "transaction_date_idx": { + "name": "transaction_date_idx", + "columns": [ + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "btree", + "with": {} + }, + "transaction_deleted_idx": { + "name": "transaction_deleted_idx", + "columns": [ + { + "expression": "deleted", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "btree", + "with": {} + }, + "transaction_merchant_name_idx": { + "name": "transaction_merchant_name_idx", + "columns": [ + { + "expression": "merchant_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "btree", + "with": {} + }, + "transaction_fingerprint_idx": { + "name": "transaction_fingerprint_idx", + "columns": [ + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "transaction_upload_id_upload_id_fk": { + "name": "transaction_upload_id_upload_id_fk", + "tableFrom": "transaction", + "tableTo": "upload", + "columnsFrom": ["upload_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "transaction_tenant_id_user_tenant_tenant_id_fk": { + "name": "transaction_tenant_id_user_tenant_tenant_id_fk", + "tableFrom": "transaction", + "tableTo": "user_tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["tenant_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "transaction_category_id_category_id_fk": { + "name": "transaction_category_id_category_id_fk", + "tableFrom": "transaction", + "tableTo": "category", + "columnsFrom": ["category_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.two_factor": { + "name": "two_factor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backup_codes": { + "name": "backup_codes", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "two_factor_user_id_user_id_fk": { + "name": "two_factor_user_id_user_id_fk", + "tableFrom": "two_factor", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.upload": { + "name": "upload", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "file_type": { + "name": "file_type", + "type": "file_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pdf'" + }, + "status": { + "name": "status", + "type": "upload_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "encrypted_password": { + "name": "encrypted_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failed_reason": { + "name": "failed_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "page_count": { + "name": "page_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "file_deleted": { + "name": "file_deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "retry_count": { + "name": "retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "upload_tenant_id_idx": { + "name": "upload_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "btree", + "with": {} + }, + "upload_deleted_idx": { + "name": "upload_deleted_idx", + "columns": [ + { + "expression": "deleted", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "upload_tenant_id_user_tenant_tenant_id_fk": { + "name": "upload_tenant_id_user_tenant_tenant_id_fk", + "tableFrom": "upload", + "tableTo": "user_tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["tenant_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "two_factor_enabled": { + "name": "two_factor_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_profile": { + "name": "user_profile", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "work_type": { + "name": "work_type", + "type": "work_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "primary_use": { + "name": "primary_use", + "type": "primary_use", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "industry": { + "name": "industry", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "onboarding_completed": { + "name": "onboarding_completed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_profile_user_id_user_id_fk": { + "name": "user_profile_user_id_user_id_fk", + "tableFrom": "user_profile", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_profile_user_id_unique": { + "name": "user_profile_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_tenant": { + "name": "user_tenant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id_hash": { + "name": "user_id_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id_encrypted": { + "name": "user_id_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dek_encrypted": { + "name": "dek_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_tenant_tenant_id_idx": { + "name": "user_tenant_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_tenant_user_id_hash_idx": { + "name": "user_tenant_user_id_hash_idx", + "columns": [ + { + "expression": "user_id_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_tenant_tenant_id_unique": { + "name": "user_tenant_tenant_id_unique", + "nullsNotDistinct": false, + "columns": ["tenant_id"] + }, + "user_tenant_user_id_hash_unique": { + "name": "user_tenant_user_id_hash_unique", + "nullsNotDistinct": false, + "columns": ["user_id_hash"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_access": { + "name": "granted_access", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "granted_at": { + "name": "granted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "waitlist_email_idx": { + "name": "waitlist_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "waitlist_granted_access_idx": { + "name": "waitlist_granted_access_idx", + "columns": [ + { + "expression": "granted_access", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.file_type": { + "name": "file_type", + "schema": "public", + "values": ["pdf", "csv"] + }, + "public.primary_use": { + "name": "primary_use", + "schema": "public", + "values": ["personal", "business", "both"] + }, + "public.upload_status": { + "name": "upload_status", + "schema": "public", + "values": [ + "queued", + "processing", + "completed", + "failed", + "cancelled", + "waiting_for_password", + "waiting_for_csv_answers" + ] + }, + "public.work_type": { + "name": "work_type", + "schema": "public", + "values": [ + "employed", + "self_employed", + "business_owner", + "student", + "retired", + "unemployed" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 1add646..97453bb 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -232,6 +232,20 @@ "when": 1768518111955, "tag": "0032_mean_bruce_banner", "breakpoints": true + }, + { + "idx": 33, + "version": "7", + "when": 1768676140372, + "tag": "0033_wooden_the_captain", + "breakpoints": true + }, + { + "idx": 34, + "version": "7", + "when": 1768768268466, + "tag": "0034_true_nekra", + "breakpoints": true } ] } diff --git a/package.json b/package.json index ee864d0..34a18e6 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,7 @@ "next": "15.3.8", "next-themes": "0.4.6", "nuqs": "2.5.0", + "papaparse": "5.5.3", "pg": "8.14.1", "qrcode.react": "4.2.0", "react": "19.0.0", @@ -93,7 +94,7 @@ "react-resizable-panels": "2.1.7", "recharts": "2.15.2", "resend": "6.6.0", - "server-only": "^0.0.1", + "server-only": "0.0.1", "sonner": "2.0.3", "superjson": "2.2.2", "tailwind-merge": "3.2.0", @@ -109,6 +110,7 @@ "@tailwindcss/postcss": "4", "@trigger.dev/build": "4.3.1", "@types/node": "20", + "@types/papaparse": "5.5.2", "@types/pg": "8.11.13", "@types/react": "19", "@types/react-dom": "19", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0419d6a..3621443 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -188,6 +188,9 @@ importers: nuqs: specifier: 2.5.0 version: 2.5.0(next@15.3.8(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0) + papaparse: + specifier: 5.5.3 + version: 5.5.3 pg: specifier: 8.14.1 version: 8.14.1 @@ -228,7 +231,7 @@ importers: specifier: 6.6.0 version: 6.6.0(@react-email/render@1.2.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)) server-only: - specifier: ^0.0.1 + specifier: 0.0.1 version: 0.0.1 sonner: specifier: 2.0.3 @@ -270,6 +273,9 @@ importers: '@types/node': specifier: '20' version: 20.17.30 + '@types/papaparse': + specifier: 5.5.2 + version: 5.5.2 '@types/pg': specifier: 8.11.13 version: 8.11.13 @@ -2581,6 +2587,9 @@ packages: '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} + '@types/papaparse@5.5.2': + resolution: {integrity: sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==} + '@types/pg@8.11.13': resolution: {integrity: sha512-6kXByGkvRvwXLuyaWzsebs2du6+XuAB2CuMsuzP7uaihQahshVgSmB22Pmh0vQMkQ1h5+PZU0d+Di1o+WpVWJg==} @@ -4268,6 +4277,9 @@ packages: resolution: {integrity: sha512-U1etNYuMJoIz3ZXSrrySFjsXQTWOx2/jdi86L+2pRvph/qMKL6sbcCYdH23fqsbm8TH2Gn0OybpT4eSFlCVHww==} engines: {node: '>=4'} + papaparse@5.5.3: + resolution: {integrity: sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -7334,6 +7346,10 @@ snapshots: '@types/normalize-package-data@2.4.4': {} + '@types/papaparse@5.5.2': + dependencies: + '@types/node': 20.17.30 + '@types/pg@8.11.13': dependencies: '@types/node': 20.17.30 @@ -8806,6 +8822,8 @@ snapshots: p-try@1.0.0: {} + papaparse@5.5.3: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 diff --git a/src/components/logged-in/nav-main.tsx b/src/components/logged-in/nav-main.tsx index c63864c..73da56f 100644 --- a/src/components/logged-in/nav-main.tsx +++ b/src/components/logged-in/nav-main.tsx @@ -19,6 +19,11 @@ import { trpc } from "@/lib/trpc/client"; import { uploadToSignedUrlAction } from "@/server/actions/uploads"; import type { SignedUploadUrl } from "@/server/routers/uploads"; import { Kbd } from "../ui/kbd"; +import { CsvConfigDialog } from "./uploads/csv-config-dialog"; + +function isCsvFile(file: File): boolean { + return file.type === "text/csv" || file.name.toLowerCase().endsWith(".csv"); +} const IMPORT_BANK_STATEMENT_SHORTCUT = "I"; @@ -37,6 +42,10 @@ export function NavMain({ }: NavMainProps) { const [files, setFiles] = useState(null); const [isUploading, setIsUploading] = useState(false); + const [csvDialogConfig, setCsvDialogConfig] = useState<{ + uploadId: string; + fileName: string; + } | null>(null); const pathname = usePathname(); const router = useRouter(); const internalFileInputRef = useRef(null); @@ -45,6 +54,30 @@ export function NavMain({ useHotkeys(IMPORT_BANK_STATEMENT_SHORTCUT, () => handleImportClick()); + const { mutate: createCsvUpload } = trpc.uploads.createCsvUpload.useMutation({ + onSuccess: (data, variables) => { + toast.dismiss("upload-bank-statement"); + toast.success("CSV uploaded", { + id: "upload-csv", + description: "Please provide some additional information.", + }); + setIsUploading(false); + setCsvDialogConfig({ + uploadId: data.uploadId, + fileName: variables.fileName, + }); + setFiles(null); + }, + onError: (error) => { + toast.dismiss("upload-bank-statement"); + setIsUploading(false); + toast.error(error.message, { + id: "upload-csv", + description: null, + }); + }, + }); + const { mutate: createSignedUploadUrls } = trpc.uploads.createSignedUploadUrls.useMutation({ onMutate: () => { @@ -63,13 +96,41 @@ export function NavMain({ }, onSuccess: async ({ uploadUrls }) => { const successfulUploads = await uploadToSignedUrls(uploadUrls); - processUploads({ - files: successfulUploads.map((upload) => ({ - fileSize: upload.file.size, - fileName: upload.file.name, - filePath: upload.signedUrlConfig.path, - })), - }); + + const csvUploads = successfulUploads.filter((u) => isCsvFile(u.file)); + const pdfUploads = successfulUploads.filter((u) => !isCsvFile(u.file)); + + if (pdfUploads.length > 0) { + processUploads({ + files: pdfUploads.map((upload) => ({ + fileSize: upload.file.size, + fileName: upload.file.name, + filePath: upload.signedUrlConfig.path, + })), + }); + } + + if (csvUploads.length > 0) { + const firstCsv = csvUploads[0]; + const skippedCsvPaths = + csvUploads.length > 1 + ? csvUploads.slice(1).map((u) => u.signedUrlConfig.path) + : undefined; + + createCsvUpload({ + fileName: firstCsv.file.name, + filePath: firstCsv.signedUrlConfig.path, + fileSize: firstCsv.file.size, + cleanupFilePaths: skippedCsvPaths, + }); + + if (csvUploads.length > 1) { + toast.info("Only one CSV can be processed at a time", { + description: "Additional CSV files were skipped.", + }); + } + } + setFiles(null); }, }); @@ -164,7 +225,7 @@ export function NavMain({ + {csvDialogConfig && ( + { + setCsvDialogConfig(null); + router.push("/uploads"); + }} + uploadId={csvDialogConfig.uploadId} + /> + )} + + )} + + {analysisData && !isAnalyzing && ( +
+ {questions.length > 0 && ( +
+ {questions.map((question) => ( + ( + + {question.label} + {question.description && ( +

+ {question.description} +

+ )} + + {renderQuestionControl(question, { + value: field.value ?? "", + onChange: field.onChange, + })} + + +
+ )} + /> + ))} +
+ )} +
+ )} + + + + + + + + + + ); +} diff --git a/src/components/logged-in/uploads/status-badge.tsx b/src/components/logged-in/uploads/status-badge.tsx index 149f432..c0d32f7 100644 --- a/src/components/logged-in/uploads/status-badge.tsx +++ b/src/components/logged-in/uploads/status-badge.tsx @@ -20,6 +20,7 @@ const statusLabel: Record = { failed: "Failed", cancelled: "Cancelled", waiting_for_password: "Waiting for password", + waiting_for_csv_answers: "Awaiting answers", }; const statusIcon: Record = { @@ -29,6 +30,7 @@ const statusIcon: Record = { failed:
, cancelled:
, waiting_for_password:
, + waiting_for_csv_answers:
, }; export function StatusBadge({ status, failedReason }: StatusBadgeProps) { diff --git a/src/components/logged-in/uploads/upload-item.tsx b/src/components/logged-in/uploads/upload-item.tsx index 850b495..4448412 100644 --- a/src/components/logged-in/uploads/upload-item.tsx +++ b/src/components/logged-in/uploads/upload-item.tsx @@ -20,6 +20,7 @@ import { useInvalidateUploads } from "@/hooks/use-uploads"; import { trpc } from "@/lib/trpc/client"; import { formatBytes } from "@/lib/utils"; import { DoubleConfirmationAlertDialog } from "../double-confirmation-alert-dialog"; +import { CsvConfigDialog } from "./csv-config-dialog"; import { FileName } from "./file-name"; import { PasswordDialog } from "./password-dialog"; import { StatusBadge } from "./status-badge"; @@ -34,7 +35,7 @@ interface UploadItemProps { | "failedReason" | "fileSize" | "metadata" - | "pdfDeleted" + | "fileDeleted" | "retryCount" >; isSelected?: boolean; @@ -134,6 +135,14 @@ export function UploadItem({ )} + {upload.status === "waiting_for_csv_answers" && ( + + + + )} + {CANCELLABLE_STATUSES.includes(upload.status) && ( ; + inferredMapping?: { + dateColumn?: string; + merchantColumn?: string; + amountColumn?: string; + descriptionColumn?: string; + }; + preview?: { + headers: string[]; + sampleRows: string[][]; + }; +} + export interface UploadMetadata { documentType?: string | null; bankName?: string | null; @@ -165,6 +193,7 @@ export interface UploadMetadata { value: string; }[] | null; + csvConfig?: CsvConfig; } export type Upload = typeof upload.$inferSelect; @@ -179,12 +208,13 @@ export const upload = pgTable( fileName: text("file_name").notNull(), filePath: text("file_path").notNull(), fileSize: integer("file_size").notNull(), + fileType: fileTypeEnum("file_type").notNull().default("pdf"), status: uploadStatusEnum("status").notNull().default("queued"), encryptedPassword: text("encrypted_password"), failedReason: text("failed_reason"), metadata: jsonb("metadata").$type(), pageCount: integer("page_count"), - pdfDeleted: boolean("pdf_deleted").notNull().default(false), + fileDeleted: boolean("file_deleted").notNull().default(false), retryCount: integer("retry_count").notNull().default(0), deleted: boolean("deleted").notNull().default(false), createdAt: timestamp("created_at").notNull().defaultNow(), diff --git a/src/server/routers/uploads.ts b/src/server/routers/uploads.ts index c768d91..20de132 100644 --- a/src/server/routers/uploads.ts +++ b/src/server/routers/uploads.ts @@ -1,16 +1,98 @@ import { randomUUID } from "node:crypto"; import { tasks } from "@trigger.dev/sdk/v3"; import { TRPCError } from "@trpc/server"; +import { generateObject } from "ai"; import { and, desc, eq, ilike, inArray } from "drizzle-orm"; +import Papa from "papaparse"; import { z } from "zod"; import { CANCELLABLE_STATUSES, DELETABLE_STATUSES } from "@/constants/uploads"; import { db } from "@/db"; -import { transaction, upload } from "@/db/schema"; +import { type CsvQuestion, transaction, upload } from "@/db/schema"; import { encryptPassword } from "@/lib/crypto"; import { createClient } from "@/lib/supabase/server"; import { paginationSchema } from "@/schemas/pagination"; import { protectedProcedure, router } from "../trpc"; +const csvQuestionSchema = z.object({ + id: z.string(), + type: z.enum(["text", "select", "date", "boolean"]), + label: z.string(), + description: z.string().optional(), + options: z.array(z.string()).optional(), + required: z.boolean(), + defaultValue: z.string().optional(), +}); + +const csvAnalysisResultSchema = z.object({ + questions: z.array(csvQuestionSchema), + inferredMapping: z.object({ + dateColumn: z.string().optional(), + merchantColumn: z.string().optional(), + amountColumn: z.string().optional(), + descriptionColumn: z.string().optional(), + }), +}); + +function parseCsvContent( + content: string, + maxRows = 50 +): { headers: string[]; rows: string[][] } { + const result = Papa.parse(content, { + header: false, + skipEmptyLines: true, + preview: maxRows + 1, + }); + + if (result.data.length === 0) { + return { headers: [], rows: [] }; + } + + const headers = result.data[0]; + const rows = result.data.slice(1, maxRows + 1); + + return { headers, rows }; +} + +function buildCsvAnalysisPrompt(headers: string[], sampleRows: string[][]) { + const rowsText = sampleRows + .slice(0, 20) + .map((row, i) => `${i + 1}. ${row.join(" | ")}`) + .join("\n"); + + return `Analyze this CSV bank statement export. + +## CSV Headers +${headers.join(", ")} + +## Sample Rows (first 20) +${rowsText} + +## YOUR TASK +1. INFER COLUMN MAPPING: Identify which columns contain: + - Date (and likely format) + - Merchant/Description + - Amount (or separate credit/debit columns) + - Optional: description/memo column + +2. GENERATE QUESTIONS: Create minimal questions for context you cannot infer. + Only ask what's truly needed for accurate extraction. + + Consider asking about: + - Bank name (helps identify merchant patterns) + - Currency (critical if not in CSV) + - Account type (checking/savings/credit card) + - Date format (only if ambiguous between MM/DD and DD/MM) + - Sign convention (only if unclear whether positive = expense or income) + + Rules for questions: + - Use "text" type for open-ended answers (bank name, currency) + - Use "select" type when there are clear options (account type, date format) + - Use "boolean" type for yes/no questions (sign convention) + - Keep questions concise but clear + - Only ask 2-4 questions maximum + - Don't ask about things you can clearly infer from the data`; +} + async function getExistingUpload(id: string, tenantId: string) { const [existingUpload] = await db .select({ @@ -19,7 +101,7 @@ async function getExistingUpload(id: string, tenantId: string) { status: upload.status, filePath: upload.filePath, pageCount: upload.pageCount, - pdfDeleted: upload.pdfDeleted, + fileDeleted: upload.fileDeleted, retryCount: upload.retryCount, }) .from(upload) @@ -179,7 +261,7 @@ export const uploadsRouter = router({ status: upload.status, failedReason: upload.failedReason, metadata: upload.metadata, - pdfDeleted: upload.pdfDeleted, + fileDeleted: upload.fileDeleted, retryCount: upload.retryCount, createdAt: upload.createdAt, }) @@ -227,7 +309,7 @@ export const uploadsRouter = router({ }); } - if (existingUpload.pdfDeleted) { + if (existingUpload.fileDeleted) { throw new TRPCError({ code: "BAD_REQUEST", message: "Cannot retry: original PDF has been deleted.", @@ -457,4 +539,234 @@ export const uploadsRouter = router({ latestUploadDate: latestUpload?.createdAt ?? null, }; }), + createCsvUpload: protectedProcedure + .input( + z.object({ + fileName: z.string(), + filePath: z.string(), + fileSize: z.number().max(1024 * 1024, "CSV file must be under 1MB"), + cleanupFilePaths: z.array(z.string()).max(10).optional(), + }) + ) + .mutation(async ({ input, ctx }) => { + const [newUpload] = await ctx.db + .insert(upload) + .values({ + tenantId: ctx.tenant.tenantId, + fileName: input.fileName, + filePath: input.filePath, + fileSize: input.fileSize, + fileType: "csv", + status: "waiting_for_csv_answers", + }) + .returning({ id: upload.id }); + + if (input.cleanupFilePaths && input.cleanupFilePaths.length > 0) { + const existingUploads = await ctx.db + .select({ filePath: upload.filePath }) + .from(upload) + .where(inArray(upload.filePath, input.cleanupFilePaths)); + + const existingPaths = new Set(existingUploads.map((u) => u.filePath)); + const orphanedPaths = input.cleanupFilePaths.filter( + (p) => !existingPaths.has(p) + ); + + if (orphanedPaths.length > 0) { + const supabase = await createClient({ admin: true }); + await supabase.storage.from("bank-statements").remove(orphanedPaths); + } + } + + return { uploadId: newUpload.id }; + }), + analyzeCsv: protectedProcedure + .input(z.object({ uploadId: z.string().uuid() })) + .mutation(async ({ input, ctx }) => { + const [existingUpload] = await ctx.db + .select({ + id: upload.id, + filePath: upload.filePath, + status: upload.status, + metadata: upload.metadata, + deleted: upload.deleted, + }) + .from(upload) + .where( + and( + eq(upload.id, input.uploadId), + eq(upload.tenantId, ctx.tenant.tenantId) + ) + ); + + if (!existingUpload || existingUpload.deleted) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Upload not found.", + }); + } + + if (existingUpload.status !== "waiting_for_csv_answers") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Upload is not waiting for CSV answers.", + }); + } + + if (existingUpload.metadata?.csvConfig?.questions) { + return { + questions: existingUpload.metadata.csvConfig + .questions as CsvQuestion[], + preview: existingUpload.metadata.csvConfig.preview ?? { + headers: [] as string[], + sampleRows: [] as string[][], + }, + inferredMapping: existingUpload.metadata.csvConfig.inferredMapping, + }; + } + + const supabase = await createClient({ admin: true }); + const { data: fileData, error: downloadError } = await supabase.storage + .from("bank-statements") + .download(existingUpload.filePath); + + if (downloadError || !fileData) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Failed to download CSV: ${downloadError?.message}`, + }); + } + + const csvContent = await fileData.text(); + const { headers, rows } = parseCsvContent(csvContent, 50); + + if (headers.length === 0) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "CSV file appears to be empty or invalid.", + }); + } + + try { + const analysisResult = await generateObject({ + model: "anthropic/claude-haiku-4.5", + mode: "json", + schemaName: "csv-analysis", + schemaDescription: + "Analysis result for a CSV bank statement with questions and column mapping.", + schema: csvAnalysisResultSchema, + messages: [ + { + role: "user", + content: buildCsvAnalysisPrompt(headers, rows), + }, + ], + }); + + const questions = analysisResult.object.questions; + const inferredMapping = analysisResult.object.inferredMapping; + const preview = { + headers, + sampleRows: rows.slice(0, 5), + }; + + await ctx.db + .update(upload) + .set({ + metadata: { + ...existingUpload.metadata, + csvConfig: { + questions, + inferredMapping, + preview, + }, + }, + }) + .where(eq(upload.id, input.uploadId)); + + return { + questions, + preview, + inferredMapping, + }; + } catch { + await ctx.db + .update(upload) + .set({ + status: "failed", + failedReason: "Failed to analyze CSV file. Please try again.", + }) + .where(eq(upload.id, input.uploadId)); + + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to analyze CSV file. Please try again.", + }); + } + }), + submitCsvAnswers: protectedProcedure + .input( + z.object({ + uploadId: z.string().uuid(), + answers: z.record(z.string(), z.string()), + }) + ) + .mutation(async ({ input, ctx }) => { + const [existingUpload] = await ctx.db + .select({ + id: upload.id, + status: upload.status, + metadata: upload.metadata, + deleted: upload.deleted, + }) + .from(upload) + .where( + and( + eq(upload.id, input.uploadId), + eq(upload.tenantId, ctx.tenant.tenantId) + ) + ); + + if (!existingUpload || existingUpload.deleted) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Upload not found.", + }); + } + + if (existingUpload.status !== "waiting_for_csv_answers") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Upload is not waiting for CSV answers.", + }); + } + + const questions = existingUpload.metadata?.csvConfig?.questions ?? []; + for (const question of questions) { + if (question.required && !input.answers[question.id]?.trim()) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Required answer missing: ${question.label}`, + }); + } + } + + await ctx.db + .update(upload) + .set({ + status: "queued", + metadata: { + ...existingUpload.metadata, + csvConfig: { + ...existingUpload.metadata?.csvConfig, + answers: input.answers, + }, + }, + }) + .where(eq(upload.id, input.uploadId)); + + await tasks.trigger("csv-breakdown", { uploadId: input.uploadId }); + + return { success: true }; + }), }); diff --git a/src/trigger/ai/categorize-and-import-transactions.ts b/src/trigger/ai/categorize-and-import-transactions.ts index 420a0f1..4cb2484 100644 --- a/src/trigger/ai/categorize-and-import-transactions.ts +++ b/src/trigger/ai/categorize-and-import-transactions.ts @@ -167,7 +167,7 @@ export const categorizeAndImportTransactionsTask = task({ } else { await db .update(upload) - .set({ pdfDeleted: true }) + .set({ fileDeleted: true }) .where(eq(upload.id, payload.uploadId)); } @@ -463,7 +463,7 @@ export const categorizeAndImportTransactionsTask = task({ } else { await db .update(upload) - .set({ pdfDeleted: true }) + .set({ fileDeleted: true }) .where(eq(upload.id, payload.uploadId)); } } diff --git a/src/trigger/ai/csv-breakdown.ts b/src/trigger/ai/csv-breakdown.ts new file mode 100644 index 0000000..1925935 --- /dev/null +++ b/src/trigger/ai/csv-breakdown.ts @@ -0,0 +1,111 @@ +import { AbortTaskRunError, logger, task } from "@trigger.dev/sdk/v3"; +import { and, eq, ne } from "drizzle-orm"; +import { db } from "@/db"; +import { upload, user } from "@/db/schema"; +import { env } from "@/env"; +import { getUserIdFromTenant } from "@/lib/tenant"; +import { sendUploadFailedTask } from "@/trigger/emails/send-upload-failed"; +import { categorizeAndImportTransactionsTask } from "./categorize-and-import-transactions"; +import { extractTransactionsCsvTask } from "./extract-transactions-csv"; + +export const csvBreakdownTask = task({ + id: "csv-breakdown", + run: async (payload: { uploadId: string }, { ctx }) => { + logger.info(`Processing CSV upload ${payload.uploadId}...`, { + payload, + ctx, + }); + + const [existingUpload] = await db + .select({ + id: upload.id, + fileName: upload.fileName, + filePath: upload.filePath, + tenantId: upload.tenantId, + metadata: upload.metadata, + }) + .from(upload) + .where(eq(upload.id, payload.uploadId)); + + if (!existingUpload) { + throw new AbortTaskRunError(`Upload ${payload.uploadId} not found`); + } + + const userId = await getUserIdFromTenant(existingUpload.tenantId); + if (!userId) { + throw new AbortTaskRunError("Tenant not found for upload"); + } + + const [uploadUser] = await db + .select({ email: user.email }) + .from(user) + .where(eq(user.id, userId)); + + if (!uploadUser) { + throw new AbortTaskRunError("User not found for upload"); + } + + await db + .update(upload) + .set({ status: "processing" }) + .where(eq(upload.id, payload.uploadId)); + + logger.info(`Extracting transactions from CSV ${payload.uploadId}...`); + const extractionResult = await extractTransactionsCsvTask + .triggerAndWait({ uploadId: payload.uploadId }) + .unwrap(); + + logger.info( + `Extracted ${extractionResult.transactions.length} transactions. Sending to categorize and import task...` + ); + await categorizeAndImportTransactionsTask.trigger({ + uploadId: payload.uploadId, + tenantId: extractionResult.tenantId, + transactions: extractionResult.transactions, + statementCurrency: extractionResult.statementCurrency, + openingBalance: extractionResult.openingBalance, + closingBalance: extractionResult.closingBalance, + }); + + return { success: true }; + }, + catchError: async ({ ctx, error, payload }) => { + logger.error(`Run ${ctx.run.id} failed`, { payload, error }); + + const [failedUpload] = await db + .select({ fileName: upload.fileName, tenantId: upload.tenantId }) + .from(upload) + .where(eq(upload.id, payload.uploadId)); + + await db + .update(upload) + .set({ + status: "failed", + failedReason: + "I'm sorry, I had a hard time processing your CSV file. Please try again later.", + }) + .where( + and(eq(upload.id, payload.uploadId), ne(upload.status, "cancelled")) + ); + + if (failedUpload) { + const userId = await getUserIdFromTenant(failedUpload.tenantId); + if (userId) { + const [uploadUser] = await db + .select({ email: user.email }) + .from(user) + .where(eq(user.id, userId)); + + if (uploadUser) { + await sendUploadFailedTask.trigger({ + to: uploadUser.email, + fileName: failedUpload.fileName, + uploadsLink: `${env.NEXT_PUBLIC_APP_URL}/uploads`, + }); + } + } + } + + return { skipRetrying: true }; + }, +}); diff --git a/src/trigger/ai/extract-transactions-csv.ts b/src/trigger/ai/extract-transactions-csv.ts new file mode 100644 index 0000000..2e4dd8f --- /dev/null +++ b/src/trigger/ai/extract-transactions-csv.ts @@ -0,0 +1,280 @@ +import { AbortTaskRunError, logger, retry, task } from "@trigger.dev/sdk/v3"; +import { generateObject } from "ai"; +import { eq } from "drizzle-orm"; +import { z } from "zod"; +import { db } from "@/db"; +import { upload } from "@/db/schema"; +import { createLambdaClient } from "@/lib/supabase/server"; + +const extractedTransactionSchema = z.object({ + date: z + .string() + .describe( + 'Transaction date in ISO format YYYY-MM-DD (e.g., "2025-01-15"). Parse dates carefully from the CSV.' + ), + merchantName: z + .string() + .describe( + 'Merchant name EXACTLY as shown in CSV. Keep prefixes like "Ifd*", "Pag*", "MP*". Only remove: installment suffixes (1/3, PARC 01/12) and payment method indicators at the end.' + ), + description: z + .string() + .optional() + .describe( + 'Additional transaction details if present (e.g., "Online purchase", "ATM withdrawal", installment info "2/6").' + ), + amount: z + .number() + .describe( + "Amount in CENTS as integer. POSITIVE for money OUT (purchases, payments, withdrawals, fees). NEGATIVE for money IN (deposits, refunds, cashback, interest received). Example: $10.50 expense = 1050, $25.00 refund = -2500." + ), + currency: z + .string() + .describe( + 'ISO 4217 currency code (e.g., "USD", "BRL", "EUR"). Use the statement\'s primary currency.' + ), +}); + +const extractionResultSchema = z.object({ + transactions: z.array(extractedTransactionSchema), + statementCurrency: z + .string() + .describe("Primary currency of the statement (ISO 4217 code)."), + openingBalance: z + .number() + .optional() + .describe("Opening/previous balance in cents if clearly visible."), + closingBalance: z + .number() + .optional() + .describe("Closing/ending balance in cents if clearly visible."), +}); + +function buildSystemPrompt( + answers: Record, + inferredMapping?: { + dateColumn?: string; + merchantColumn?: string; + amountColumn?: string; + descriptionColumn?: string; + } +) { + const userContext = Object.entries(answers) + .map(([key, value]) => `- ${key}: ${value}`) + .join("\n"); + + const mappingContext = inferredMapping + ? ` +## INFERRED COLUMN MAPPING +- Date column: ${inferredMapping.dateColumn ?? "Not specified"} +- Merchant/Description column: ${inferredMapping.merchantColumn ?? "Not specified"} +- Amount column: ${inferredMapping.amountColumn ?? "Not specified"} +- Description/Memo column: ${inferredMapping.descriptionColumn ?? "Not specified"}` + : ""; + + return `You are an expert financial document analyst specializing in extracting transactions from CSV bank statement exports. + +## USER-PROVIDED CONTEXT +${userContext} +${mappingContext} + +## YOUR TASK +Analyze the provided CSV data and extract ALL transactions into a structured format with perfect accuracy. + +## CRITICAL RULES + +### Transaction Identification +- Extract EVERY transaction row from the CSV +- Skip header rows and summary rows +- Watch for blank rows or separator rows - skip them +- Do NOT skip any actual transactions - completeness is critical + +### Date Parsing +- Convert all dates to ISO format: YYYY-MM-DD +- Handle various formats: "Jan 15", "15/01/2025", "01-15-25", "2025-01-15", etc. +- Use the user's date format context if provided +- If year is missing, infer from context + +### Merchant Name (KEEP AS-IS) +Keep the merchant name EXACTLY as shown. Preserve all prefixes and identifiers. + +KEEP prefixes like: +- "Ifd*", "Pag*", "MP*", "PAG*", "SQ*", etc. + +ONLY REMOVE: +- Installment suffixes at the end (1/3, 2/6, PARC 01/12) +- Payment method indicators at the very end + +### Amount Sign Convention (IMPORTANT!) +Based on the user's context or your analysis of the data: +- POSITIVE amounts = Money leaving the account (expenses, purchases, payments, fees, withdrawals) +- NEGATIVE amounts = Money entering the account (deposits, refunds, credits, interest, income) + +Convert to cents: $10.50 = 1050, R$ 25,00 = 2500 + +If the CSV has separate debit/credit columns: +- Debits → POSITIVE (expenses) +- Credits → NEGATIVE (income) + +If the CSV has a single amount column, interpret based on: +- User's context about sign convention +- Column headers or indicators +- Transaction descriptions (look for "deposit", "refund", "payment", etc.) + +### Deduplication +- Same date + same merchant + same amount = likely duplicate, extract only once + +### Accuracy Focus +- Preserve exact spelling and formatting of merchant names +- Do not guess or infer missing data - only extract what is clearly visible`; +} + +function buildUserPrompt(csvContent: string) { + return `## CSV CONTENT +\`\`\` +${csvContent} +\`\`\` + +## INSTRUCTIONS +1. Parse ALL transaction rows from the CSV +2. Keep merchant names exactly as shown (preserve prefixes) +3. Apply correct sign convention (positive = expense, negative = income) +4. Convert amounts to cents +5. Do not skip any transactions + +## EXAMPLE OUTPUT FORMAT +\`\`\`json +{ + "transactions": [ + { + "date": "2025-01-15", + "merchantName": "Ifd*Japakitos", + "description": "2/3", + "amount": 2500, + "currency": "BRL" + }, + { + "date": "2025-01-14", + "merchantName": "NETFLIX.COM", + "amount": 3990, + "currency": "BRL" + }, + { + "date": "2025-01-10", + "merchantName": "TED RECEBIDO", + "description": "Salary deposit", + "amount": -500000, + "currency": "BRL" + } + ], + "statementCurrency": "BRL" +} +\`\`\` + +Now analyze the CSV and extract all transactions with perfect accuracy.`; +} + +export const extractTransactionsCsvTask = task({ + id: "extract-transactions-csv", + retry: { randomize: false }, + run: async (payload: { uploadId: string }, { ctx }) => { + logger.info(`Extracting transactions from CSV ${payload.uploadId}...`, { + payload, + ctx, + }); + + const [uploadRecord] = await db + .select({ + id: upload.id, + filePath: upload.filePath, + tenantId: upload.tenantId, + metadata: upload.metadata, + }) + .from(upload) + .where(eq(upload.id, payload.uploadId)); + + if (!uploadRecord) { + throw new AbortTaskRunError( + `Upload ${payload.uploadId} not found in database` + ); + } + + const csvConfig = uploadRecord.metadata?.csvConfig; + if (!csvConfig?.answers) { + throw new AbortTaskRunError( + `Upload ${payload.uploadId} missing CSV configuration answers` + ); + } + + const supabase = createLambdaClient(); + const { data: signedUrlData, error: signedUrlError } = + await supabase.storage + .from("bank-statements") + .createSignedUrl(uploadRecord.filePath, 60 * 15); + + if (signedUrlError || !signedUrlData?.signedUrl) { + logger.error(`Failed to get signed URL for upload ${payload.uploadId}`, { + error: signedUrlError?.message, + }); + throw new Error("Failed to get signed URL"); + } + + logger.info(`Downloading CSV file for upload ${payload.uploadId}...`); + const response = await retry.fetch(signedUrlData.signedUrl, { + method: "GET", + }); + const csvContent = await response.text(); + + logger.info("Extracting transactions with AI..."); + const result = await generateObject({ + model: "anthropic/claude-sonnet-4.5", + mode: "json", + schemaName: "extract-transactions-csv", + schemaDescription: + "Transactions extracted from a CSV bank statement with high accuracy.", + schema: extractionResultSchema, + messages: [ + { + role: "system", + content: buildSystemPrompt( + csvConfig.answers, + csvConfig.inferredMapping + ), + }, + { + role: "user", + content: buildUserPrompt(csvContent), + }, + ], + }); + + logger.info("CSV transaction extraction complete", { + transactionCount: result.object.transactions.length, + statementCurrency: result.object.statementCurrency, + openingBalance: result.object.openingBalance, + closingBalance: result.object.closingBalance, + }); + + const supabaseAdmin = createLambdaClient(); + const { error: deleteError } = await supabaseAdmin.storage + .from("bank-statements") + .remove([uploadRecord.filePath]); + + if (deleteError) { + logger.warn(`Failed to delete CSV file: ${deleteError.message}`); + } else { + await db + .update(upload) + .set({ fileDeleted: true }) + .where(eq(upload.id, payload.uploadId)); + logger.info(`Deleted CSV file for upload ${payload.uploadId}`); + } + + return { + success: true, + uploadId: payload.uploadId, + tenantId: uploadRecord.tenantId, + ...result.object, + }; + }, +});