diff --git a/.env.example b/.env.example index 537ea73..28133e5 100644 --- a/.env.example +++ b/.env.example @@ -1,11 +1,16 @@ # Public app configuration NEXT_PUBLIC_APP_URL=http://localhost:3000 +# Optional comma-separated hostnames for proxied Next.js dev server origins. +NEXT_ALLOWED_DEV_ORIGINS= # Server-only configuration DATABASE_URL= SESSION_SECRET= # Base64-encoded 32-byte key. Multiple keys require stable key-id:base64-key entries. ENCRYPTION_KEY= +OPENAI_API_KEY= +# Optional; defaults to gpt-4.1-mini. +OPENAI_REPORT_ASSISTANT_MODEL= # Onchain configuration GNOSIS_RPC_URL= diff --git a/README.md b/README.md index a0f4967..9c20d1b 100644 --- a/README.md +++ b/README.md @@ -26,9 +26,27 @@ pnpm dev Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +For a local HTTPS proxy or remote development tunnel, set the public app URL +and allowed dev origins in your ignored local environment file: + +```env +NEXT_PUBLIC_APP_URL=https://your-dev-origin.example +NEXT_ALLOWED_DEV_ORIGINS=your-dev-origin.example +``` + +Then run the proxy-friendly dev server: + +```bash +pnpm dev:proxy +``` + +Point your local proxy at [http://127.0.0.1:3001](http://127.0.0.1:3001). +Keep machine-specific proxy hostnames and URLs out of committed source. + ## Scripts - `pnpm dev`: start the local Next.js dev server. +- `pnpm dev:proxy`: start the local Next.js dev server on `127.0.0.1:3001` for a local HTTPS proxy or remote development tunnel. - `pnpm build`: create a production build. - `pnpm start`: run the production build. - `pnpm lint`: run ESLint. diff --git a/drizzle/0018_workable_miss_america.sql b/drizzle/0018_workable_miss_america.sql new file mode 100644 index 0000000..b1413df --- /dev/null +++ b/drizzle/0018_workable_miss_america.sql @@ -0,0 +1,9 @@ +CREATE TABLE "report_assistant_rate_limits" ( + "key" text PRIMARY KEY NOT NULL, + "count" integer DEFAULT 0 NOT NULL, + "reset_at" timestamp with time zone NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE INDEX "report_assistant_rate_limits_reset_at_idx" ON "report_assistant_rate_limits" USING btree ("reset_at"); \ No newline at end of file diff --git a/drizzle/meta/0018_snapshot.json b/drizzle/meta/0018_snapshot.json new file mode 100644 index 0000000..40a528d --- /dev/null +++ b/drizzle/meta/0018_snapshot.json @@ -0,0 +1,3483 @@ +{ + "id": "86c95f0e-bdf6-4e84-a481-25d5486b94bb", + "prevId": "ff86d741-30e9-45db-b97a-77e362681d59", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.app_users": { + "name": "app_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "wallet_address": { + "name": "wallet_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name_encrypted": { + "name": "display_name_encrypted", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_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()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "app_users_wallet_address_unique": { + "name": "app_users_wallet_address_unique", + "columns": [ + { + "expression": "lower(\"wallet_address\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_events": { + "name": "audit_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "actor_wallet_address": { + "name": "actor_wallet_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "audit_action", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "subject_table": { + "name": "subject_table", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_id": { + "name": "subject_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "quarter_id": { + "name": "quarter_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_events_subject_idx": { + "name": "audit_events_subject_idx", + "columns": [ + { + "expression": "subject_table", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_actor_user_id_idx": { + "name": "audit_events_actor_user_id_idx", + "columns": [ + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_quarter_id_idx": { + "name": "audit_events_quarter_id_idx", + "columns": [ + { + "expression": "quarter_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_created_at_idx": { + "name": "audit_events_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_events_actor_user_id_app_users_id_fk": { + "name": "audit_events_actor_user_id_app_users_id_fk", + "tableFrom": "audit_events", + "tableTo": "app_users", + "columnsFrom": [ + "actor_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "audit_events_quarter_id_quarters_id_fk": { + "name": "audit_events_quarter_id_quarters_id_fk", + "tableFrom": "audit_events", + "tableTo": "quarters", + "columnsFrom": [ + "quarter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cleric_roles": { + "name": "cleric_roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "wallet_address": { + "name": "wallet_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by_user_id": { + "name": "granted_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_by_user_id": { + "name": "revoked_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "cleric_roles_wallet_address_idx": { + "name": "cleric_roles_wallet_address_idx", + "columns": [ + { + "expression": "lower(\"wallet_address\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cleric_roles_active_idx": { + "name": "cleric_roles_active_idx", + "columns": [ + { + "expression": "lower(\"wallet_address\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "revoked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cleric_roles_granted_by_user_id_app_users_id_fk": { + "name": "cleric_roles_granted_by_user_id_app_users_id_fk", + "tableFrom": "cleric_roles", + "tableTo": "app_users", + "columnsFrom": [ + "granted_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "cleric_roles_revoked_by_user_id_app_users_id_fk": { + "name": "cleric_roles_revoked_by_user_id_app_users_id_fk", + "tableFrom": "cleric_roles", + "tableTo": "app_users", + "columnsFrom": [ + "revoked_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dao_proposals": { + "name": "dao_proposals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "dao_address": { + "name": "dao_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chain_id": { + "name": "chain_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "proposal_id": { + "name": "proposal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "proposal_number": { + "name": "proposal_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_tx_hash": { + "name": "execution_tx_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "executed_at": { + "name": "executed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "daohaus_url": { + "name": "daohaus_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "raw_metadata": { + "name": "raw_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "dao_proposals_chain_dao_proposal_unique": { + "name": "dao_proposals_chain_dao_proposal_unique", + "columns": [ + { + "expression": "chain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"dao_address\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "proposal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "dao_proposals_chain_execution_tx_unique": { + "name": "dao_proposals_chain_execution_tx_unique", + "columns": [ + { + "expression": "chain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"execution_tx_hash\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "dao_proposals_executed_at_idx": { + "name": "dao_proposals_executed_at_idx", + "columns": [ + { + "expression": "executed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "dao_proposals_status_idx": { + "name": "dao_proposals_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.entities": { + "name": "entities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "type": { + "name": "type", + "type": "entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "name_encrypted": { + "name": "name_encrypted", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "website_encrypted": { + "name": "website_encrypted", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "notes_encrypted": { + "name": "notes_encrypted", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_member": { + "name": "is_member", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "archived_at": { + "name": "archived_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()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "entities_type_idx": { + "name": "entities_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "entities_archived_at_idx": { + "name": "entities_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.entity_addresses": { + "name": "entity_addresses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "entity_id": { + "name": "entity_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chain_id": { + "name": "chain_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "label_encrypted": { + "name": "label_encrypted", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "entity_addresses_entity_id_idx": { + "name": "entity_addresses_entity_id_idx", + "columns": [ + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "entity_addresses_address_idx": { + "name": "entity_addresses_address_idx", + "columns": [ + { + "expression": "address", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "entity_addresses_chain_address_unique": { + "name": "entity_addresses_chain_address_unique", + "columns": [ + { + "expression": "coalesce(\"chain_id\", -1)", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "lower(\"address\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "entity_addresses_entity_id_entities_id_fk": { + "name": "entity_addresses_entity_id_entities_id_fk", + "tableFrom": "entity_addresses", + "tableTo": "entities", + "columnsFrom": [ + "entity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ledger_entries": { + "name": "ledger_entries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "quarter_id": { + "name": "quarter_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "ledger_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source_external_id": { + "name": "source_external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "ledger_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'uncategorized'" + }, + "verification_status": { + "name": "verification_status", + "type": "verification_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'verified'" + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "chain_id": { + "name": "chain_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tx_hash": { + "name": "tx_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "treasury_account_id": { + "name": "treasury_account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "treasury_transaction_transfer_id": { + "name": "treasury_transaction_transfer_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "asset_symbol": { + "name": "asset_symbol", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "asset_amount": { + "name": "asset_amount", + "type": "numeric(36, 18)", + "primaryKey": false, + "notNull": true + }, + "usd_amount": { + "name": "usd_amount", + "type": "numeric(18, 2)", + "primaryKey": false, + "notNull": true + }, + "counterparty_entity_id": { + "name": "counterparty_entity_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "raid_id": { + "name": "raid_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "rip_id": { + "name": "rip_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "notes_encrypted": { + "name": "notes_encrypted", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "source_metadata": { + "name": "source_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ledger_entries_quarter_id_idx": { + "name": "ledger_entries_quarter_id_idx", + "columns": [ + { + "expression": "quarter_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ledger_entries_category_idx": { + "name": "ledger_entries_category_idx", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ledger_entries_source_external_id_unique": { + "name": "ledger_entries_source_external_id_unique", + "columns": [ + { + "expression": "source_external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ledger_entries_tx_hash_idx": { + "name": "ledger_entries_tx_hash_idx", + "columns": [ + { + "expression": "tx_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ledger_entries_treasury_transfer_unique": { + "name": "ledger_entries_treasury_transfer_unique", + "columns": [ + { + "expression": "treasury_transaction_transfer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ledger_entries_raid_id_idx": { + "name": "ledger_entries_raid_id_idx", + "columns": [ + { + "expression": "raid_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ledger_entries_rip_id_idx": { + "name": "ledger_entries_rip_id_idx", + "columns": [ + { + "expression": "rip_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ledger_entries_occurred_at_idx": { + "name": "ledger_entries_occurred_at_idx", + "columns": [ + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ledger_entries_quarter_id_quarters_id_fk": { + "name": "ledger_entries_quarter_id_quarters_id_fk", + "tableFrom": "ledger_entries", + "tableTo": "quarters", + "columnsFrom": [ + "quarter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ledger_entries_treasury_account_id_treasury_accounts_id_fk": { + "name": "ledger_entries_treasury_account_id_treasury_accounts_id_fk", + "tableFrom": "ledger_entries", + "tableTo": "treasury_accounts", + "columnsFrom": [ + "treasury_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ledger_entries_treasury_transaction_transfer_id_treasury_transaction_transfers_id_fk": { + "name": "ledger_entries_treasury_transaction_transfer_id_treasury_transaction_transfers_id_fk", + "tableFrom": "ledger_entries", + "tableTo": "treasury_transaction_transfers", + "columnsFrom": [ + "treasury_transaction_transfer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ledger_entries_counterparty_entity_id_entities_id_fk": { + "name": "ledger_entries_counterparty_entity_id_entities_id_fk", + "tableFrom": "ledger_entries", + "tableTo": "entities", + "columnsFrom": [ + "counterparty_entity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ledger_entries_raid_id_raids_id_fk": { + "name": "ledger_entries_raid_id_raids_id_fk", + "tableFrom": "ledger_entries", + "tableTo": "raids", + "columnsFrom": [ + "raid_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ledger_entries_rip_id_rips_id_fk": { + "name": "ledger_entries_rip_id_rips_id_fk", + "tableFrom": "ledger_entries", + "tableTo": "rips", + "columnsFrom": [ + "rip_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.membership_activities": { + "name": "membership_activities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "quarter_id": { + "name": "quarter_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "dao_proposal_id": { + "name": "dao_proposal_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "membership_activity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "dao_address": { + "name": "dao_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chain_id": { + "name": "chain_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "member_address": { + "name": "member_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipient_address": { + "name": "recipient_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tx_hash": { + "name": "tx_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "executed_at": { + "name": "executed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "proposal_id": { + "name": "proposal_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "proposal_title": { + "name": "proposal_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "asset_address": { + "name": "asset_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "asset_symbol": { + "name": "asset_symbol", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "asset_amount": { + "name": "asset_amount", + "type": "numeric(36, 18)", + "primaryKey": false, + "notNull": false + }, + "usd_amount": { + "name": "usd_amount", + "type": "numeric(18, 2)", + "primaryKey": false, + "notNull": false + }, + "shares": { + "name": "shares", + "type": "numeric(36, 18)", + "primaryKey": false, + "notNull": false + }, + "loot": { + "name": "loot", + "type": "numeric(36, 18)", + "primaryKey": false, + "notNull": false + }, + "raw_metadata": { + "name": "raw_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "membership_activities_chain_tx_type_member_unique": { + "name": "membership_activities_chain_tx_type_member_unique", + "columns": [ + { + "expression": "chain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tx_hash\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"member_address\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "membership_activities_quarter_id_idx": { + "name": "membership_activities_quarter_id_idx", + "columns": [ + { + "expression": "quarter_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "membership_activities_dao_proposal_id_idx": { + "name": "membership_activities_dao_proposal_id_idx", + "columns": [ + { + "expression": "dao_proposal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "membership_activities_executed_at_idx": { + "name": "membership_activities_executed_at_idx", + "columns": [ + { + "expression": "executed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "membership_activities_type_idx": { + "name": "membership_activities_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "membership_activities_member_address_idx": { + "name": "membership_activities_member_address_idx", + "columns": [ + { + "expression": "member_address", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "membership_activities_quarter_id_quarters_id_fk": { + "name": "membership_activities_quarter_id_quarters_id_fk", + "tableFrom": "membership_activities", + "tableTo": "quarters", + "columnsFrom": [ + "quarter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "membership_activities_dao_proposal_id_dao_proposals_id_fk": { + "name": "membership_activities_dao_proposal_id_dao_proposals_id_fk", + "tableFrom": "membership_activities", + "tableTo": "dao_proposals", + "columnsFrom": [ + "dao_proposal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.quarter_balance_snapshots": { + "name": "quarter_balance_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "quarter_id": { + "name": "quarter_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "treasury_account_id": { + "name": "treasury_account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "boundary": { + "name": "boundary", + "type": "quarter_balance_boundary", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "account_address": { + "name": "account_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chain_id": { + "name": "chain_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "block_number": { + "name": "block_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "block_timestamp": { + "name": "block_timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "symbol": { + "name": "symbol", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "decimals": { + "name": "decimals", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "raw_amount": { + "name": "raw_amount", + "type": "numeric(78, 0)", + "primaryKey": false, + "notNull": true + }, + "balance": { + "name": "balance", + "type": "numeric(36, 18)", + "primaryKey": false, + "notNull": true + }, + "usd_price": { + "name": "usd_price", + "type": "numeric(18, 8)", + "primaryKey": false, + "notNull": true + }, + "usd_value": { + "name": "usd_value", + "type": "numeric(18, 2)", + "primaryKey": false, + "notNull": true + }, + "price_source": { + "name": "price_source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "quarter_balance_snapshots_quarter_boundary_idx": { + "name": "quarter_balance_snapshots_quarter_boundary_idx", + "columns": [ + { + "expression": "quarter_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "boundary", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "quarter_balance_snapshots_unique": { + "name": "quarter_balance_snapshots_unique", + "columns": [ + { + "expression": "quarter_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "boundary", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"account_address\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "symbol", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "quarter_balance_snapshots_quarter_id_quarters_id_fk": { + "name": "quarter_balance_snapshots_quarter_id_quarters_id_fk", + "tableFrom": "quarter_balance_snapshots", + "tableTo": "quarters", + "columnsFrom": [ + "quarter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "quarter_balance_snapshots_treasury_account_id_treasury_accounts_id_fk": { + "name": "quarter_balance_snapshots_treasury_account_id_treasury_accounts_id_fk", + "tableFrom": "quarter_balance_snapshots", + "tableTo": "treasury_accounts", + "columnsFrom": [ + "treasury_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.quarter_balance_validations": { + "name": "quarter_balance_validations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "quarter_id": { + "name": "quarter_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "quarter_balance_validation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "checked_count": { + "name": "checked_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "variance_count": { + "name": "variance_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "excluded_count": { + "name": "excluded_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_variance_usd": { + "name": "total_variance_usd", + "type": "numeric(18, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "acknowledgement_note_encrypted": { + "name": "acknowledgement_note_encrypted", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "acknowledged_by_wallet_address": { + "name": "acknowledged_by_wallet_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "acknowledged_at": { + "name": "acknowledged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "source_sync_run_id": { + "name": "source_sync_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "quarter_balance_validations_quarter_id_unique": { + "name": "quarter_balance_validations_quarter_id_unique", + "columns": [ + { + "expression": "quarter_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "quarter_balance_validations_status_idx": { + "name": "quarter_balance_validations_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "quarter_balance_validations_quarter_id_quarters_id_fk": { + "name": "quarter_balance_validations_quarter_id_quarters_id_fk", + "tableFrom": "quarter_balance_validations", + "tableTo": "quarters", + "columnsFrom": [ + "quarter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.quarter_sync_statuses": { + "name": "quarter_sync_statuses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "quarter_id": { + "name": "quarter_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "overall_status": { + "name": "overall_status", + "type": "quarter_sync_overall_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "current_step": { + "name": "current_step", + "type": "quarter_sync_step", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "transactions_status": { + "name": "transactions_status", + "type": "quarter_sync_step_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "proposals_status": { + "name": "proposals_status", + "type": "quarter_sync_step_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "membership_status": { + "name": "membership_status", + "type": "quarter_sync_step_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "balances_status": { + "name": "balances_status", + "type": "quarter_sync_step_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "finalize_status": { + "name": "finalize_status", + "type": "quarter_sync_step_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "transactions_started_at": { + "name": "transactions_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "transactions_completed_at": { + "name": "transactions_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "proposals_started_at": { + "name": "proposals_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "proposals_completed_at": { + "name": "proposals_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "membership_started_at": { + "name": "membership_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "membership_completed_at": { + "name": "membership_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "balances_started_at": { + "name": "balances_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "balances_completed_at": { + "name": "balances_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finalize_started_at": { + "name": "finalize_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finalize_completed_at": { + "name": "finalize_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "transactions_error": { + "name": "transactions_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "proposals_error": { + "name": "proposals_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "membership_error": { + "name": "membership_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "balances_error": { + "name": "balances_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "finalize_error": { + "name": "finalize_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "imported_transactions": { + "name": "imported_transactions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "imported_transfers": { + "name": "imported_transfers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "scanned_transfers": { + "name": "scanned_transfers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sync_error_count": { + "name": "sync_error_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "proposal_linked_transactions": { + "name": "proposal_linked_transactions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "proposal_matches": { + "name": "proposal_matches", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "membership_activities": { + "name": "membership_activities", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_synced_at": { + "name": "last_synced_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()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "quarter_sync_statuses_quarter_id_unique": { + "name": "quarter_sync_statuses_quarter_id_unique", + "columns": [ + { + "expression": "quarter_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "quarter_sync_statuses_overall_status_idx": { + "name": "quarter_sync_statuses_overall_status_idx", + "columns": [ + { + "expression": "overall_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "quarter_sync_statuses_last_synced_at_idx": { + "name": "quarter_sync_statuses_last_synced_at_idx", + "columns": [ + { + "expression": "last_synced_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "quarter_sync_statuses_quarter_id_quarters_id_fk": { + "name": "quarter_sync_statuses_quarter_id_quarters_id_fk", + "tableFrom": "quarter_sync_statuses", + "tableTo": "quarters", + "columnsFrom": [ + "quarter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.quarters": { + "name": "quarters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "year": { + "name": "year", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "quarter": { + "name": "quarter", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "starts_on": { + "name": "starts_on", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "ends_on": { + "name": "ends_on", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "quarter_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "reopened_at": { + "name": "reopened_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()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "quarters_year_quarter_unique": { + "name": "quarters_year_quarter_unique", + "columns": [ + { + "expression": "year", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "quarter", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "quarters_status_idx": { + "name": "quarters_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.raids": { + "name": "raids", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "client_entity_id": { + "name": "client_entity_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name_encrypted": { + "name": "name_encrypted", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "notes_encrypted": { + "name": "notes_encrypted", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_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()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "raids_client_entity_id_idx": { + "name": "raids_client_entity_id_idx", + "columns": [ + { + "expression": "client_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "raids_archived_at_idx": { + "name": "raids_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "raids_client_entity_id_entities_id_fk": { + "name": "raids_client_entity_id_entities_id_fk", + "tableFrom": "raids", + "tableTo": "entities", + "columnsFrom": [ + "client_entity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.report_assistant_rate_limits": { + "name": "report_assistant_rate_limits", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reset_at": { + "name": "reset_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "report_assistant_rate_limits_reset_at_idx": { + "name": "report_assistant_rate_limits_reset_at_idx", + "columns": [ + { + "expression": "reset_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rips": { + "name": "rips", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title_encrypted": { + "name": "title_encrypted", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "url_encrypted": { + "name": "url_encrypted", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by_wallet_address": { + "name": "created_by_wallet_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "rips_created_at_idx": { + "name": "rips_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rips_created_by_wallet_address_idx": { + "name": "rips_created_by_wallet_address_idx", + "columns": [ + { + "expression": "lower(\"created_by_wallet_address\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.treasury_accounts": { + "name": "treasury_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name_encrypted": { + "name": "name_encrypted", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chain_id": { + "name": "chain_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "treasury_account_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "is_dao_controlled": { + "name": "is_dao_controlled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "notes_encrypted": { + "name": "notes_encrypted", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_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()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "treasury_accounts_chain_address_unique": { + "name": "treasury_accounts_chain_address_unique", + "columns": [ + { + "expression": "chain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "address", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "treasury_accounts_type_idx": { + "name": "treasury_accounts_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "treasury_accounts_archived_at_idx": { + "name": "treasury_accounts_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.treasury_balance_assets": { + "name": "treasury_balance_assets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "symbol": { + "name": "symbol", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "decimals": { + "name": "decimals", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "raw_amount": { + "name": "raw_amount", + "type": "numeric(78, 0)", + "primaryKey": false, + "notNull": true + }, + "balance": { + "name": "balance", + "type": "numeric(36, 18)", + "primaryKey": false, + "notNull": true + }, + "usd_price": { + "name": "usd_price", + "type": "numeric(18, 8)", + "primaryKey": false, + "notNull": true + }, + "usd_value": { + "name": "usd_value", + "type": "numeric(18, 2)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "treasury_balance_assets_snapshot_id_idx": { + "name": "treasury_balance_assets_snapshot_id_idx", + "columns": [ + { + "expression": "snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "treasury_balance_assets_snapshot_symbol_unique": { + "name": "treasury_balance_assets_snapshot_symbol_unique", + "columns": [ + { + "expression": "snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "symbol", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "treasury_balance_assets_snapshot_id_treasury_balance_snapshots_id_fk": { + "name": "treasury_balance_assets_snapshot_id_treasury_balance_snapshots_id_fk", + "tableFrom": "treasury_balance_assets", + "tableTo": "treasury_balance_snapshots", + "columnsFrom": [ + "snapshot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.treasury_balance_snapshots": { + "name": "treasury_balance_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_address": { + "name": "account_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chain_id": { + "name": "chain_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "treasury_snapshot_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "total_usd": { + "name": "total_usd", + "type": "numeric(18, 2)", + "primaryKey": false, + "notNull": true + }, + "synced_at": { + "name": "synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "treasury_balance_snapshots_chain_account_synced_idx": { + "name": "treasury_balance_snapshots_chain_account_synced_idx", + "columns": [ + { + "expression": "chain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_address", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "synced_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "treasury_balance_snapshots_synced_at_idx": { + "name": "treasury_balance_snapshots_synced_at_idx", + "columns": [ + { + "expression": "synced_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.treasury_transaction_transfers": { + "name": "treasury_transaction_transfers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "treasury_transaction_id": { + "name": "treasury_transaction_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "treasury_account_id": { + "name": "treasury_account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "transfer_id": { + "name": "transfer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "treasury_transfer_direction", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "transfer_type": { + "name": "transfer_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_address": { + "name": "account_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chain_id": { + "name": "chain_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tx_hash": { + "name": "tx_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "executed_at": { + "name": "executed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "from_address": { + "name": "from_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "to_address": { + "name": "to_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_address": { + "name": "token_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "asset_symbol": { + "name": "asset_symbol", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "asset_name": { + "name": "asset_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "decimals": { + "name": "decimals", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "raw_amount": { + "name": "raw_amount", + "type": "numeric(78, 0)", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(36, 18)", + "primaryKey": false, + "notNull": true + }, + "usd_price": { + "name": "usd_price", + "type": "numeric(18, 8)", + "primaryKey": false, + "notNull": false + }, + "usd_amount": { + "name": "usd_amount", + "type": "numeric(18, 2)", + "primaryKey": false, + "notNull": false + }, + "raw_metadata": { + "name": "raw_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "treasury_transaction_transfers_chain_transfer_unique": { + "name": "treasury_transaction_transfers_chain_transfer_unique", + "columns": [ + { + "expression": "chain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"account_address\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "transfer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "treasury_transaction_transfers_transaction_id_idx": { + "name": "treasury_transaction_transfers_transaction_id_idx", + "columns": [ + { + "expression": "treasury_transaction_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "treasury_transaction_transfers_treasury_account_id_idx": { + "name": "treasury_transaction_transfers_treasury_account_id_idx", + "columns": [ + { + "expression": "treasury_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "treasury_transaction_transfers_tx_hash_idx": { + "name": "treasury_transaction_transfers_tx_hash_idx", + "columns": [ + { + "expression": "tx_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "treasury_transaction_transfers_executed_at_idx": { + "name": "treasury_transaction_transfers_executed_at_idx", + "columns": [ + { + "expression": "executed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "treasury_transaction_transfers_treasury_transaction_id_treasury_transactions_id_fk": { + "name": "treasury_transaction_transfers_treasury_transaction_id_treasury_transactions_id_fk", + "tableFrom": "treasury_transaction_transfers", + "tableTo": "treasury_transactions", + "columnsFrom": [ + "treasury_transaction_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "treasury_transaction_transfers_treasury_account_id_treasury_accounts_id_fk": { + "name": "treasury_transaction_transfers_treasury_account_id_treasury_accounts_id_fk", + "tableFrom": "treasury_transaction_transfers", + "tableTo": "treasury_accounts", + "columnsFrom": [ + "treasury_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.treasury_transactions": { + "name": "treasury_transactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "dao_proposal_id": { + "name": "dao_proposal_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "treasury_account_id": { + "name": "treasury_account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "ledger_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "account_address": { + "name": "account_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chain_id": { + "name": "chain_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tx_hash": { + "name": "tx_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "safe_transaction_hash": { + "name": "safe_transaction_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transaction_type": { + "name": "transaction_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "executed_at": { + "name": "executed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "block_number": { + "name": "block_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "raw_metadata": { + "name": "raw_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "imported_at": { + "name": "imported_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "treasury_transactions_chain_account_tx_unique": { + "name": "treasury_transactions_chain_account_tx_unique", + "columns": [ + { + "expression": "chain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"account_address\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "lower(\"tx_hash\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "treasury_transactions_tx_hash_idx": { + "name": "treasury_transactions_tx_hash_idx", + "columns": [ + { + "expression": "tx_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "treasury_transactions_account_executed_idx": { + "name": "treasury_transactions_account_executed_idx", + "columns": [ + { + "expression": "chain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_address", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "executed_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "treasury_transactions_treasury_account_id_idx": { + "name": "treasury_transactions_treasury_account_id_idx", + "columns": [ + { + "expression": "treasury_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "treasury_transactions_dao_proposal_id_idx": { + "name": "treasury_transactions_dao_proposal_id_idx", + "columns": [ + { + "expression": "dao_proposal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "treasury_transactions_dao_proposal_id_dao_proposals_id_fk": { + "name": "treasury_transactions_dao_proposal_id_dao_proposals_id_fk", + "tableFrom": "treasury_transactions", + "tableTo": "dao_proposals", + "columnsFrom": [ + "dao_proposal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "treasury_transactions_treasury_account_id_treasury_accounts_id_fk": { + "name": "treasury_transactions_treasury_account_id_treasury_accounts_id_fk", + "tableFrom": "treasury_transactions", + "tableTo": "treasury_accounts", + "columnsFrom": [ + "treasury_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.audit_action": { + "name": "audit_action", + "schema": "public", + "values": [ + "create", + "update", + "delete", + "import", + "classify", + "publish", + "reopen", + "grant_role", + "revoke_role" + ] + }, + "public.entity_type": { + "name": "entity_type", + "schema": "public", + "values": [ + "client", + "provider", + "subcontractor" + ] + }, + "public.ledger_category": { + "name": "ledger_category", + "schema": "public", + "values": [ + "raid_revenue", + "raid_spoils", + "subcontractor_payout", + "rip_expense", + "provider_expense", + "member_dues", + "ragequit", + "treasury_transfer", + "uncategorized" + ] + }, + "public.ledger_source": { + "name": "ledger_source", + "schema": "public", + "values": [ + "main_safe", + "side_vault", + "operator", + "manual", + "bank_csv", + "dao_proposal" + ] + }, + "public.membership_activity_type": { + "name": "membership_activity_type", + "schema": "public", + "values": [ + "join", + "ragequit" + ] + }, + "public.quarter_balance_boundary": { + "name": "quarter_balance_boundary", + "schema": "public", + "values": [ + "opening", + "closing" + ] + }, + "public.quarter_balance_validation_status": { + "name": "quarter_balance_validation_status", + "schema": "public", + "values": [ + "not_ready", + "needs_review", + "validated", + "acknowledged" + ] + }, + "public.quarter_status": { + "name": "quarter_status", + "schema": "public", + "values": [ + "draft", + "ready_for_review", + "published", + "reopened" + ] + }, + "public.quarter_sync_overall_status": { + "name": "quarter_sync_overall_status", + "schema": "public", + "values": [ + "idle", + "running", + "success", + "partial", + "failed" + ] + }, + "public.quarter_sync_step": { + "name": "quarter_sync_step", + "schema": "public", + "values": [ + "transactions", + "proposals", + "membership", + "balances", + "finalize" + ] + }, + "public.quarter_sync_step_status": { + "name": "quarter_sync_step_status", + "schema": "public", + "values": [ + "pending", + "running", + "success", + "failed" + ] + }, + "public.treasury_account_type": { + "name": "treasury_account_type", + "schema": "public", + "values": [ + "main_safe", + "side_vault", + "operator", + "bank" + ] + }, + "public.treasury_snapshot_status": { + "name": "treasury_snapshot_status", + "schema": "public", + "values": [ + "pending_live_sync", + "synced", + "stale_syncing", + "partial", + "failed" + ] + }, + "public.treasury_transfer_direction": { + "name": "treasury_transfer_direction", + "schema": "public", + "values": [ + "inflow", + "outflow", + "internal" + ] + }, + "public.verification_status": { + "name": "verification_status", + "schema": "public", + "values": [ + "verified", + "unverified" + ] + } + }, + "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 2969331..97ea464 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -127,6 +127,13 @@ "when": 1781793308630, "tag": "0017_happy_silver_fox", "breakpoints": true + }, + { + "idx": 18, + "version": "7", + "when": 1782744358925, + "tag": "0018_workable_miss_america", + "breakpoints": true } ] } \ No newline at end of file diff --git a/next.config.ts b/next.config.ts index e9ffa30..7173e08 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,11 @@ import type { NextConfig } from "next"; +const allowedDevOrigins = process.env.NEXT_ALLOWED_DEV_ORIGINS?.split(",") + .map((origin) => origin.trim()) + .filter(Boolean); + const nextConfig: NextConfig = { - /* config options here */ + ...(allowedDevOrigins?.length ? { allowedDevOrigins } : {}), }; export default nextConfig; diff --git a/package.json b/package.json index 7f3776e..a30a392 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ }, "scripts": { "dev": "next dev", + "dev:proxy": "next dev -H 127.0.0.1 -p 3001", "build": "next build", "start": "next start", "lint": "eslint .", diff --git a/src/app/admin/quarters/actions.ts b/src/app/admin/quarters/actions.ts index 07fddd8..f827242 100644 --- a/src/app/admin/quarters/actions.ts +++ b/src/app/admin/quarters/actions.ts @@ -21,6 +21,18 @@ import { getQuarterSyncStatus, isQuarterSyncFresh } from "@/lib/quarter-sync"; const QUARTERS_PATH = "/admin/quarters"; const REPORTS_PATH = "/reports"; +export type QuarterStatusFormState = { + error: string | null; + saved: boolean; +}; + +class QuarterStatusBusinessError extends Error { + constructor(message: string) { + super(message); + this.name = "QuarterStatusBusinessError"; + } +} + function getString(formData: FormData, key: string) { const value = formData.get(key); @@ -37,7 +49,7 @@ function getTargetStatus(value: string): QuarterStatus { return value; } - throw new Error("Unsupported quarter status"); + throw new QuarterStatusBusinessError("Unsupported quarter status"); } async function requireAdminSession() { @@ -59,7 +71,7 @@ async function getQuarterById(id: string) { .limit(1); if (!quarter) { - throw new Error("Quarter not found"); + throw new QuarterStatusBusinessError("Quarter not found"); } return quarter; @@ -88,25 +100,31 @@ function assertAllowedTransition({ targetStatus: QuarterStatus; }) { if (currentStatus === targetStatus) { - throw new Error("Quarter is already in that status"); + throw new QuarterStatusBusinessError("Quarter is already in that status"); } if (targetStatus === "reopened") { if (currentStatus !== "published") { - throw new Error("Only published quarters can be reopened"); + throw new QuarterStatusBusinessError( + "Only published quarters can be reopened", + ); } if (!reason) { - throw new Error("Reopen reason is required"); + throw new QuarterStatusBusinessError("Reopen reason is required"); } } if (currentStatus === "published" && targetStatus !== "reopened") { - throw new Error("Published quarters must be reopened before editing"); + throw new QuarterStatusBusinessError( + "Published quarters must be reopened before editing", + ); } if (targetStatus === "published" && currentStatus !== "ready_for_review") { - throw new Error("Mark the quarter ready before publishing"); + throw new QuarterStatusBusinessError( + "Mark the quarter ready before publishing", + ); } } @@ -155,7 +173,7 @@ export async function updateQuarterStatus(formData: FormData) { const reason = getString(formData, "reason"); if (!id) { - throw new Error("Quarter is required"); + throw new QuarterStatusBusinessError("Quarter is required"); } const quarter = await getQuarterById(id); @@ -169,7 +187,7 @@ export async function updateQuarterStatus(formData: FormData) { if (targetStatus === "ready_for_review" || targetStatus === "published") { const syncStatus = await getQuarterSyncStatus(id); if (!isQuarterSyncFresh({ quarter, syncStatus })) { - throw new Error( + throw new QuarterStatusBusinessError( targetStatus === "published" ? "Sync quarter activity after the quarter has ended before publishing" : "Sync quarter activity after the quarter has ended before marking it ready", @@ -182,7 +200,7 @@ export async function updateQuarterStatus(formData: FormData) { }); if (summary.unclassifiedTransfers > 0) { - throw new Error( + throw new QuarterStatusBusinessError( targetStatus === "published" ? "Classify all imported transactions before publishing this quarter" : "Classify all imported transactions before marking this quarter ready", @@ -192,7 +210,7 @@ export async function updateQuarterStatus(formData: FormData) { const validation = await getQuarterBalanceValidation(id); if (!isQuarterBalanceValidationSatisfied(validation)) { - throw new Error( + throw new QuarterStatusBusinessError( targetStatus === "published" ? "Validate balances before publishing this quarter" : "Validate balances before marking this quarter ready", @@ -216,7 +234,9 @@ export async function updateQuarterStatus(formData: FormData) { .returning(); if (!updatedQuarter) { - throw new Error("Quarter status changed. Refresh and try again."); + throw new QuarterStatusBusinessError( + "Quarter status changed. Refresh and try again.", + ); } await writeAuditEvent({ @@ -244,3 +264,23 @@ export async function updateQuarterStatus(formData: FormData) { revalidatePath(REPORTS_PATH); revalidatePath(`/reports/quarters/${id}`); } + +export async function updateQuarterStatusWithState( + _previousState: QuarterStatusFormState, + formData: FormData, +): Promise { + try { + await updateQuarterStatus(formData); + } catch (error) { + if (!(error instanceof QuarterStatusBusinessError)) { + throw error; + } + + return { + error: error.message, + saved: false, + }; + } + + return { error: null, saved: true }; +} diff --git a/src/app/admin/quarters/publish-quarter-confirmation.tsx b/src/app/admin/quarters/publish-quarter-confirmation.tsx index 797cee1..84588de 100644 --- a/src/app/admin/quarters/publish-quarter-confirmation.tsx +++ b/src/app/admin/quarters/publish-quarter-confirmation.tsx @@ -1,11 +1,19 @@ "use client"; import { LockKeyhole, X } from "lucide-react"; -import { useState } from "react"; +import { useActionState, useEffect, useState } from "react"; -import { updateQuarterStatus } from "@/app/admin/quarters/actions"; +import { + type QuarterStatusFormState, + updateQuarterStatusWithState, +} from "@/app/admin/quarters/actions"; import { Button } from "@/components/ui/button"; +const INITIAL_STATE: QuarterStatusFormState = { + error: null, + saved: false, +}; + export function PublishQuarterConfirmation({ disabled, quarterId, @@ -16,6 +24,20 @@ export function PublishQuarterConfirmation({ quarterLabel: string; }) { const [open, setOpen] = useState(false); + const [state, action, pending] = useActionState( + updateQuarterStatusWithState, + INITIAL_STATE, + ); + + useEffect(() => { + if (!state.saved) { + return undefined; + } + + const timeoutId = window.setTimeout(() => setOpen(false), 0); + + return () => window.clearTimeout(timeoutId); + }, [state.saved]); return ( <> @@ -85,15 +107,23 @@ export function PublishQuarterConfirmation({ > Cancel -
+ -
+ {state.error ? ( +

+ {state.error} +

+ ) : null} ) : null} diff --git a/src/app/api/reports/quarters/[id]/assistant/route.ts b/src/app/api/reports/quarters/[id]/assistant/route.ts new file mode 100644 index 0000000..1823e8e --- /dev/null +++ b/src/app/api/reports/quarters/[id]/assistant/route.ts @@ -0,0 +1,115 @@ +import { NextResponse } from "next/server"; +import { createHash } from "node:crypto"; + +import { getAuthSession, serializeSession } from "@/lib/auth/session"; +import { isQuarterExportReady } from "@/lib/quarter-export-readiness"; +import { getQuarterReportData } from "@/lib/quarter-report"; +import { listQuarterReportingPeriods } from "@/lib/quarters"; +import { planReportAssistantQuery } from "@/lib/report-assistant/openai-planner"; +import { guardReportAssistantPrompt } from "@/lib/report-assistant/prompt-guard"; +import { executeReportAssistantPlan } from "@/lib/report-assistant/query-executor"; +import { + checkReportAssistantRateLimit, + ReportAssistantRateLimitError, +} from "@/lib/report-assistant/rate-limit"; + +type RouteParams = { + params: Promise<{ id: string }>; +}; + +const RATE_LIMIT_WINDOW_MS = 60_000; +const RATE_LIMIT_MAX_REQUESTS = 12; + +const CLIENT_SAFE_ERROR_MESSAGES = new Set([ + "Ask a report question first.", + "Ask a shorter report question.", + "Ask a question about published report totals or rankings.", +]); + +function getActorLogIdentifier(address: string | null) { + if (!address) { + return null; + } + + return createHash("sha256") + .update(address.toLowerCase()) + .digest("hex") + .slice(0, 16); +} + +function errorResponse(message: string, status = 400) { + return NextResponse.json({ error: message }, { status }); +} + +function getAssistantErrorResponse(error: unknown) { + if (error instanceof ReportAssistantRateLimitError) { + return errorResponse(error.message, 429); + } + + if ( + error instanceof Error && + CLIENT_SAFE_ERROR_MESSAGES.has(error.message) + ) { + return errorResponse(error.message, 400); + } + + return errorResponse("Report assistant failed.", 500); +} + +export async function POST(request: Request, { params }: RouteParams) { + const session = serializeSession(await getAuthSession()); + + if (!session.authenticated || !session.permissions?.canAccess) { + return errorResponse("Member access required.", 401); + } + + const { id } = await params; + const quarter = (await listQuarterReportingPeriods()).find( + (item) => item.id === id, + ); + + if (!quarter) { + return errorResponse("Report not found.", 404); + } + + if (quarter.status !== "published" || !isQuarterExportReady(quarter)) { + return errorResponse("The assistant only supports published reports.", 403); + } + + try { + await checkReportAssistantRateLimit({ + key: `${session.address ?? "unknown"}:${id}`, + maxRequests: RATE_LIMIT_MAX_REQUESTS, + windowMs: RATE_LIMIT_WINDOW_MS, + }); + const payload = (await request.json().catch(() => null)) as { + prompt?: unknown; + } | null; + const prompt = guardReportAssistantPrompt(payload?.prompt); + const plan = await planReportAssistantQuery({ prompt }); + const report = await getQuarterReportData(quarter); + const result = executeReportAssistantPlan({ plan, quarter, report }); + + console.info("report_assistant_query", { + actorWalletHash: getActorLogIdentifier(session.address), + chart: plan.chart, + grouping: result.provenance.grouping, + intent: plan.intent, + limit: plan.limit, + promptLength: prompt.length, + quarterId: quarter.id, + resultRows: result.table.length, + unsupportedReason: plan.unsupportedReason, + }); + + return NextResponse.json(result); + } catch (error) { + console.warn("report_assistant_error", { + actorWalletHash: getActorLogIdentifier(session.address), + error: error instanceof Error ? error.message : "Unknown error", + quarterId: quarter.id, + }); + + return getAssistantErrorResponse(error); + } +} diff --git a/src/app/reports/quarters/[id]/page.tsx b/src/app/reports/quarters/[id]/page.tsx index 72f29a7..a30fbc4 100644 --- a/src/app/reports/quarters/[id]/page.tsx +++ b/src/app/reports/quarters/[id]/page.tsx @@ -7,6 +7,8 @@ import Link from "next/link"; import { AppHeader } from "@/components/app-header"; import { CopyableAddress } from "@/components/copyable-address"; +import { ReportAssistant } from "@/components/reports/report-assistant"; +import { ReportCharts } from "@/components/reports/report-charts"; import { getAuthSession, serializeSession } from "@/lib/auth/session"; import { isQuarterExportReady } from "@/lib/quarter-export-readiness"; import { getQuarterReportData } from "@/lib/quarter-report"; @@ -318,7 +320,7 @@ export default async function QuarterReportPage({ const report = await getQuarterReportData(quarter); const subcontractorExpensePercent = report.metrics.expenses > 0 - ? report.metrics.subcontractorPayouts / report.metrics.expenses + ? report.expenseBreakdown.subcontractorPayouts / report.metrics.expenses : 0; return ( @@ -365,7 +367,7 @@ export default async function QuarterReportPage({
@@ -377,6 +379,16 @@ export default async function QuarterReportPage({
+ + + {quarter.status === "published" ? ( + + ) : null} +
diff --git a/src/components/reports/report-assistant.tsx b/src/components/reports/report-assistant.tsx new file mode 100644 index 0000000..9b7aee8 --- /dev/null +++ b/src/components/reports/report-assistant.tsx @@ -0,0 +1,403 @@ +"use client"; + +import { Bot, Pin, PinOff, Send, Sparkles } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { useToast } from "@/components/ui/toast"; +import type { ReportAssistantResponse } from "@/lib/report-assistant/types"; + +type ReportAssistantProps = { + quarterId: string; + walletAddress: string | null; +}; + +const SUGGESTED_PROMPTS = [ + "What were our top 5 raids by revenue?", + "Which clients brought in the most revenue?", + "Which subcontractor had the highest payout?", + "Which providers had the highest expenses?", + "Summarize revenue, expenses, spoils, and net.", +]; +const NON_REPORT_PROMPT_PATTERNS = [ + /^(hello|hi|hey|gm|good\s+(morning|afternoon|evening)|yo)[.!?\s]*$/i, + /^(thanks|thank\s+you|ok|okay|cool|nice)[.!?\s]*$/i, +]; + +function formatCurrency(value: number) { + return new Intl.NumberFormat("en-US", { + currency: "USD", + style: "currency", + }).format(value); +} + +function getPinnedStorageKey(quarterId: string, walletAddress: string | null) { + return `raidguild-report-assistant:${quarterId}:${walletAddress ?? "member"}`; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object"; +} + +function isStoredPinnedResponse( + value: unknown, +): value is ReportAssistantResponse { + if (!isRecord(value)) { + return false; + } + + const { answer, plan, provenance, table } = value; + + return ( + typeof answer === "string" && + isRecord(plan) && + typeof plan.intent === "string" && + isRecord(provenance) && + typeof provenance.metric === "string" && + typeof provenance.grouping === "string" && + Array.isArray(table) + ); +} + +function getStoredPinnedResponses(storageKey: string) { + try { + const stored = window.localStorage.getItem(storageKey); + + if (!stored) { + return []; + } + + const parsed = JSON.parse(stored); + + return Array.isArray(parsed) ? parsed.filter(isStoredPinnedResponse) : []; + } catch { + return []; + } +} + +function isErrorPayload(value: unknown): value is { error: string } { + return isRecord(value) && typeof value.error === "string"; +} + +function getResponseKey(response: ReportAssistantResponse) { + return [ + response.plan.intent, + response.plan.limit ?? "all", + response.plan.chart ?? "none", + response.plan.unsupportedReason ?? "supported", + response.provenance.metric, + response.provenance.grouping, + ].join(":"); +} + +function MiniChart({ response }: { response: ReportAssistantResponse }) { + if (!response.chart || response.chart.rows.length === 0) { + return null; + } + + const total = response.chart.rows.reduce((sum, row) => sum + row.value, 0); + const max = Math.max(...response.chart.rows.map((row) => row.value), 1); + + if (response.chart.type === "pie") { + return ( +
+ {response.chart.rows.map((row, index) => ( +
+
+ {row.label} + + {total > 0 ? `${Math.round((row.value / total) * 100)}%` : "0%"} + +
+
+
0 ? Math.max((row.value / total) * 100, 2) : 0}%`, + }} + /> +
+
+ ))} +
+ ); + } + + return ( +
+ {response.chart.rows.map((row, index) => ( +
+
+ {row.label} + + {formatCurrency(row.value)} + +
+
+
+
+
+ ))} +
+ ); +} + +function AssistantCard({ + isPinned, + onPinToggle, + response, +}: { + isPinned: boolean; + onPinToggle: () => void; + response: ReportAssistantResponse; +}) { + return ( +
+
+
+

+ {response.provenance.metric} by {response.provenance.grouping} +

+

{response.answer}

+
+ +
+ + {response.table.length > 0 ? ( +
+ + + + + + + + + + {response.table.map((row, index) => ( + + + + + + ))} + +
LabelEntriesValue
{row.label} + {row.entries ?? "-"} + + {formatCurrency(row.value)} +
+
+ ) : null} + + + +

+ Source: {response.provenance.quarter} + {response.provenance.lastPublishedAt + ? `, published ${new Intl.DateTimeFormat("en-US", { + dateStyle: "medium", + timeStyle: "short", + }).format(new Date(response.provenance.lastPublishedAt))}` + : ""} +

+
+ ); +} + +export function ReportAssistant({ + quarterId, + walletAddress, +}: ReportAssistantProps) { + const { showToast } = useToast(); + const storageKey = useMemo( + () => getPinnedStorageKey(quarterId, walletAddress), + [quarterId, walletAddress], + ); + const [prompt, setPrompt] = useState(""); + const [responses, setResponses] = useState([]); + const [pinnedResponses, setPinnedResponses] = useState< + ReportAssistantResponse[] + >([]); + const [isPending, setIsPending] = useState(false); + const hasLoadedPinnedResponses = useRef(false); + + useEffect(() => { + const timeout = window.setTimeout(() => { + hasLoadedPinnedResponses.current = true; + setPinnedResponses(getStoredPinnedResponses(storageKey)); + }, 0); + + return () => window.clearTimeout(timeout); + }, [storageKey]); + + useEffect(() => { + if (!hasLoadedPinnedResponses.current) { + return; + } + + window.localStorage.setItem(storageKey, JSON.stringify(pinnedResponses)); + }, [pinnedResponses, storageKey]); + + const pinnedKeys = useMemo( + () => new Set(pinnedResponses.map(getResponseKey)), + [pinnedResponses], + ); + + function togglePinned(response: ReportAssistantResponse) { + const key = getResponseKey(response); + + setPinnedResponses((current) => + current.some((item) => getResponseKey(item) === key) + ? current.filter((item) => getResponseKey(item) !== key) + : [response, ...current], + ); + } + + async function askAssistant(nextPrompt = prompt) { + const trimmedPrompt = nextPrompt.trim(); + + if (!trimmedPrompt) { + showToast("Ask a report question first."); + return; + } + + if (NON_REPORT_PROMPT_PATTERNS.some((pattern) => pattern.test(trimmedPrompt))) { + showToast("Ask a question relevant to this published report."); + return; + } + + setIsPending(true); + + try { + const response = await fetch( + `/api/reports/quarters/${quarterId}/assistant`, + { + body: JSON.stringify({ prompt: trimmedPrompt }), + headers: { "Content-Type": "application/json" }, + method: "POST", + }, + ); + const payload = (await response.json()) as unknown; + + if (!response.ok || isErrorPayload(payload)) { + throw new Error( + isErrorPayload(payload) ? payload.error : "Assistant failed.", + ); + } + + if (!isStoredPinnedResponse(payload)) { + throw new Error("Assistant returned an unexpected response."); + } + + setResponses((current) => [payload, ...current]); + setPrompt(""); + } catch (error) { + showToast(error instanceof Error ? error.message : "Assistant failed."); + } finally { + setIsPending(false); + } + } + + return ( +
+
+
+

Assistant

+

+

+

+ Ask about published report rankings and totals. +

+
+ + Published data only + +
+ + {pinnedResponses.length > 0 ? ( +
+

Pinned Widgets

+ {pinnedResponses.map((response, index) => ( + togglePinned(response)} + response={response} + /> + ))} +
+ ) : null} + +
+ {SUGGESTED_PROMPTS.map((suggestion) => ( + + ))} +
+ +
{ + event.preventDefault(); + void askAssistant(); + }} + > + + setPrompt(event.target.value)} + placeholder="Ask about ranked raids, clients, subcontractors, providers, or totals" + className="h-9 min-w-0 flex-1 rounded-md border border-input bg-background px-3 text-sm outline-none transition-colors focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50" + maxLength={500} + /> + +
+ + {responses.length > 0 ? ( +
+

Answers

+ {responses.map((response, index) => ( + togglePinned(response)} + response={response} + /> + ))} +
+ ) : null} +
+ ); +} diff --git a/src/components/reports/report-charts.tsx b/src/components/reports/report-charts.tsx new file mode 100644 index 0000000..b089c60 --- /dev/null +++ b/src/components/reports/report-charts.tsx @@ -0,0 +1,258 @@ +import type { ReactNode } from "react"; + +import type { QuarterReportData } from "@/lib/quarter-report"; + +function formatCurrency(value: number) { + return new Intl.NumberFormat("en-US", { + currency: "USD", + maximumFractionDigits: 0, + style: "currency", + }).format(value); +} + +function formatPercent(value: number) { + return new Intl.NumberFormat("en-US", { + maximumFractionDigits: 0, + style: "percent", + }).format(value); +} + +function getPercent(value: number, total: number) { + if (total <= 0) { + return 0; + } + + return Math.max((value / total) * 100, value > 0 ? 2 : 0); +} + +function ChartShell({ + children, + eyebrow, + title, +}: { + children: ReactNode; + eyebrow: string; + title: string; +}) { + return ( +
+

{eyebrow}

+

{title}

+ {children} +
+ ); +} + +function EmptyChart({ message }: { message: string }) { + return ( +
+ {message} +
+ ); +} + +function QuarterFlowChart({ report }: { report: QuarterReportData }) { + const rows = [ + { + label: "Revenue", + tone: "bg-chart-1", + value: report.metrics.revenue, + }, + { + label: "Subcontractors", + tone: "bg-chart-2", + value: -report.expenseBreakdown.subcontractorPayouts, + }, + { + label: "Providers", + tone: "bg-chart-3", + value: -report.expenseBreakdown.providerExpenses, + }, + { + label: "RIPs", + tone: "bg-chart-4", + value: -report.expenseBreakdown.ripExpenses, + }, + { + label: "Ragequits", + tone: "bg-chart-5", + value: -report.expenseBreakdown.ragequits, + }, + { + label: "Net", + tone: + report.metrics.net >= 0 ? "bg-emerald-700" : "bg-destructive", + value: report.metrics.net, + }, + ]; + const maxValue = Math.max(...rows.map((row) => Math.abs(row.value)), 1); + + return ( + +
+ {rows.map((row) => { + const width = `${getPercent(Math.abs(row.value), maxValue)}%`; + + return ( +
+
+ {row.label} + + {formatCurrency(row.value)} + +
+
+
+ {row.value < 0 ? ( +
+ ) : null} +
+
+ {row.value >= 0 ? ( +
+ ) : null} +
+
+
+ ); + })} +
+ + ); +} + +function OutflowMixChart({ report }: { report: QuarterReportData }) { + const rows = [ + { + label: "Subcontractors", + tone: "bg-chart-2", + value: report.expenseBreakdown.subcontractorPayouts, + }, + { + label: "Providers", + tone: "bg-chart-3", + value: report.expenseBreakdown.providerExpenses, + }, + { + label: "RIPs", + tone: "bg-chart-4", + value: report.expenseBreakdown.ripExpenses, + }, + { + label: "Ragequits", + tone: "bg-chart-5", + value: report.expenseBreakdown.ragequits, + }, + ].filter((row) => row.value > 0); + const total = rows.reduce((sum, row) => sum + row.value, 0); + + return ( + + {total > 0 ? ( + <> +
+ {rows.map((row) => ( +
+ ))} +
+
+ {rows.map((row) => ( +
+
+ + + + {formatPercent(row.value / total)} + +
+

+ {formatCurrency(row.value)} +

+
+ ))} +
+ + ) : ( + + )} + + ); +} + +function TopRaidRevenueChart({ report }: { report: QuarterReportData }) { + const rows = report.raidEconomics + .filter((row) => row.revenue > 0) + .slice(0, 5); + const maxRevenue = Math.max(...rows.map((row) => row.revenue), 1); + + return ( + + {rows.length > 0 ? ( +
+
+ {rows.map((row, index) => ( +
+

+ {formatCurrency(row.revenue)} +

+
+
+ ))} +
+
+ {rows.map((row) => ( +

+ {row.raid} +

+ ))} +
+
+ ) : ( + + )} + + ); +} + +export function ReportCharts({ report }: { report: QuarterReportData }) { + return ( +
+
+ + +
+ +
+ ); +} diff --git a/src/db/schema.ts b/src/db/schema.ts index bf28b5a..0ed0590 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -130,6 +130,19 @@ const timestamps = { .notNull(), }; +export const reportAssistantRateLimits = pgTable( + "report_assistant_rate_limits", + { + key: text("key").primaryKey(), + count: integer("count").default(0).notNull(), + resetAt: timestamp("reset_at", { withTimezone: true }).notNull(), + ...timestamps, + }, + (table) => [ + index("report_assistant_rate_limits_reset_at_idx").on(table.resetAt), + ], +); + export const appUsers = pgTable( "app_users", { diff --git a/src/lib/quarter-report.ts b/src/lib/quarter-report.ts index 22ce758..10b9916 100644 --- a/src/lib/quarter-report.ts +++ b/src/lib/quarter-report.ts @@ -22,17 +22,33 @@ export type QuarterReportLinkedRow = { entries: number; }; +export type QuarterReportRaidEconomicsRow = { + expectedSpoils: number; + payouts: number; + raid: string; + remainingPool: number; + revenue: number; + spoilsReceived: number; +}; + export type QuarterReportData = { balances: QuarterAccountBalanceSummary[]; + expenseBreakdown: { + providerExpenses: number; + ragequits: number; + ripExpenses: number; + subcontractorPayouts: number; + }; ledgerRows: QuarterExportLedgerRow[]; metrics: { expenses: number; net: number; + raidLinkedSubcontractorPayouts: number; revenue: number; spoilsReceived: number; - subcontractorPayouts: number; }; providerExpenses: QuarterReportLinkedRow[]; + raidEconomics: QuarterReportRaidEconomicsRow[]; ripExpenses: QuarterReportLinkedRow[]; topRaids: QuarterReportLinkedRow[]; }; @@ -70,6 +86,52 @@ function sumRaidLinkedSubcontractorPayouts(rows: QuarterExportLedgerRow[]) { }, 0); } +function getQuarterRaidEconomicsRows(ledgerRows: QuarterExportLedgerRow[]) { + const rows = new Map(); + + for (const ledgerRow of ledgerRows) { + if (!ledgerRow.raid) { + continue; + } + + const row = + rows.get(ledgerRow.raid) ?? + ({ + expectedSpoils: 0, + payouts: 0, + raid: ledgerRow.raid, + remainingPool: 0, + revenue: 0, + spoilsReceived: 0, + } satisfies QuarterReportRaidEconomicsRow); + const usdAmount = toNumber(ledgerRow.usdAmount); + + if (ledgerRow.category === "raid_revenue") { + row.revenue += usdAmount; + } + + if (ledgerRow.category === "raid_spoils") { + row.spoilsReceived += usdAmount; + } + + if (ledgerRow.category === "subcontractor_payout") { + row.payouts += usdAmount; + } + + row.expectedSpoils = row.revenue / 10; + row.remainingPool = row.revenue - row.expectedSpoils - row.payouts; + rows.set(ledgerRow.raid, row); + } + + return Array.from(rows.values()).sort((left, right) => { + if (left.revenue !== right.revenue) { + return right.revenue - left.revenue; + } + + return left.raid.localeCompare(right.raid); + }); +} + function summarizeLinkedRows({ category, fallbackLabel, @@ -119,24 +181,31 @@ export async function getQuarterReportData( listQuarterBalanceRows(quarter.id), ]); const revenue = sumRows(ledgerRows, ["raid_revenue", "member_dues"]); - const expenses = sumRows(ledgerRows, [ - "provider_expense", - "ragequit", - "rip_expense", - "subcontractor_payout", - ]); + const providerExpenses = sumRows(ledgerRows, ["provider_expense"]); + const ragequits = sumRows(ledgerRows, ["ragequit"]); + const ripExpenses = sumRows(ledgerRows, ["rip_expense"]); + const subcontractorPayouts = sumRows(ledgerRows, ["subcontractor_payout"]); + const expenses = + providerExpenses + ragequits + ripExpenses + subcontractorPayouts; const spoilsReceived = sumRows(ledgerRows, ["raid_spoils"]); - const subcontractorPayouts = sumRaidLinkedSubcontractorPayouts(ledgerRows); + const raidLinkedSubcontractorPayouts = + sumRaidLinkedSubcontractorPayouts(ledgerRows); return { balances: summarizeQuarterBalanceRows(balanceRows), + expenseBreakdown: { + providerExpenses, + ragequits, + ripExpenses, + subcontractorPayouts, + }, ledgerRows, metrics: { expenses, net: revenue - expenses, + raidLinkedSubcontractorPayouts, revenue, spoilsReceived, - subcontractorPayouts, }, providerExpenses: summarizeLinkedRows({ category: "provider_expense", @@ -144,6 +213,7 @@ export async function getQuarterReportData( getLabel: (row) => row.counterparty, rows: ledgerRows, }), + raidEconomics: getQuarterRaidEconomicsRows(ledgerRows), ripExpenses: summarizeLinkedRows({ category: "rip_expense", fallbackLabel: "Unlinked RIP", diff --git a/src/lib/report-assistant/openai-planner.ts b/src/lib/report-assistant/openai-planner.ts new file mode 100644 index 0000000..ac28cb9 --- /dev/null +++ b/src/lib/report-assistant/openai-planner.ts @@ -0,0 +1,277 @@ +import "server-only"; + +import { validateReportAssistantPlan } from "@/lib/report-assistant/query-validator"; +import type { ReportAssistantPlan } from "@/lib/report-assistant/types"; + +const DEFAULT_MODEL = "gpt-4.1-mini"; +const OUTSIDE_REPORT_DATA_PATTERNS = [ + /\b(new\s+members?|member\s+joins?|members?\s+joined|membership|joined\s+this\s+quarter)\b/i, + /\b(proposal\s+details?|daohaus|votes?|voting)\b/i, + /\b(raw\s+transactions?|transaction\s+hashes?|wallet\s+addresses?|individual\s+members?)\b/i, +]; +const NONSENSE_PATTERNS = [ + /\b(weather|recipe|sports|capital\s+of|tell\s+me\s+a\s+joke)\b/i, + /^[^a-z0-9]*$/i, + /^[a-z]{1,2}$/i, +]; +const SMALL_TALK_PATTERNS = [ + /^(hello|hi|hey|gm|good\s+(morning|afternoon|evening)|yo)[.!?\s]*$/i, + /^(thanks|thank\s+you|ok|okay|cool|nice)[.!?\s]*$/i, +]; +const BEST_REVENUE_MONTH_PATTERN = + /\b(best|top|highest|largest|most)\s+month\b.*\brevenue\b|\brevenue\b.*\b(best|top|highest|largest|most)\s+month\b/i; +const REVENUE_BY_MONTH_PATTERN = + /\brevenue\b.*\b(by|per|each)\s+month\b|\bmonthly\s+revenue\b/i; +const BEST_EXPENSE_MONTH_PATTERN = + /\b(best|top|highest|largest|most)\s+month\b.*\b(expenses?|spend|spending|costs?)\b|\b(expenses?|spend|spending|costs?)\b.*\b(best|top|highest|largest|most)\s+month\b/i; +const EXPENSES_BY_MONTH_PATTERN = + /\b(expenses?|spend|spending|costs?)\b.*\b(by|per|each)\s+month\b|\bmonthly\s+(expenses?|spend|spending|costs?)\b/i; + +const PLAN_SCHEMA = { + additionalProperties: false, + properties: { + chart: { + enum: ["bar", "pie", "table", null], + type: ["string", "null"], + }, + intent: { + enum: [ + "quarter_summary", + "top_raids_by_revenue", + "top_clients_by_revenue", + "top_subcontractors_by_payout", + "top_providers_by_expense", + "expenses_by_category", + "revenue_by_month", + "expenses_by_month", + "unsupported_report_question", + ], + type: "string", + }, + limit: { + maximum: 10, + minimum: 1, + type: ["integer", "null"], + }, + unsupportedReason: { + enum: ["outside_report_data", "small_talk", "nonsense", null], + type: ["string", "null"], + }, + }, + required: ["intent", "limit", "chart", "unsupportedReason"], + type: "object", +} as const; + +function extractOutputText(response: Record) { + if (typeof response.output_text === "string") { + return response.output_text; + } + + const output = Array.isArray(response.output) ? response.output : []; + + for (const item of output) { + if (!item || typeof item !== "object") { + continue; + } + + const content = Array.isArray((item as Record).content) + ? ((item as Record).content as unknown[]) + : []; + + for (const contentItem of content) { + if (!contentItem || typeof contentItem !== "object") { + continue; + } + + const text = (contentItem as Record).text; + + if (typeof text === "string") { + return text; + } + } + } + + return null; +} + +function getPlannerModel() { + return process.env.OPENAI_REPORT_ASSISTANT_MODEL?.trim() || DEFAULT_MODEL; +} + +function getUnsupportedPlan(prompt: string): ReportAssistantPlan | null { + if (SMALL_TALK_PATTERNS.some((pattern) => pattern.test(prompt))) { + return { + chart: null, + intent: "unsupported_report_question", + limit: null, + unsupportedReason: "small_talk", + }; + } + + if (OUTSIDE_REPORT_DATA_PATTERNS.some((pattern) => pattern.test(prompt))) { + return { + chart: null, + intent: "unsupported_report_question", + limit: null, + unsupportedReason: "outside_report_data", + }; + } + + if (NONSENSE_PATTERNS.some((pattern) => pattern.test(prompt))) { + return { + chart: null, + intent: "unsupported_report_question", + limit: null, + unsupportedReason: "nonsense", + }; + } + + return null; +} + +function getDeterministicPlan(prompt: string): ReportAssistantPlan | null { + if (BEST_REVENUE_MONTH_PATTERN.test(prompt)) { + return { + chart: null, + intent: "revenue_by_month", + limit: 1, + unsupportedReason: null, + }; + } + + if (REVENUE_BY_MONTH_PATTERN.test(prompt)) { + return { + chart: null, + intent: "revenue_by_month", + limit: null, + unsupportedReason: null, + }; + } + + if (BEST_EXPENSE_MONTH_PATTERN.test(prompt)) { + return { + chart: null, + intent: "expenses_by_month", + limit: 1, + unsupportedReason: null, + }; + } + + if (EXPENSES_BY_MONTH_PATTERN.test(prompt)) { + return { + chart: null, + intent: "expenses_by_month", + limit: null, + unsupportedReason: null, + }; + } + + return null; +} + +export async function planReportAssistantQuery({ + prompt, +}: { + prompt: string; +}): Promise { + const deterministicPlan = getDeterministicPlan(prompt); + + if (deterministicPlan) { + return deterministicPlan; + } + + const unsupportedPlan = getUnsupportedPlan(prompt); + + if (unsupportedPlan) { + return unsupportedPlan; + } + + const apiKey = process.env.OPENAI_API_KEY; + + if (!apiKey) { + throw new Error("OPENAI_API_KEY is required for the report assistant."); + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 15_000); + let response: Response; + + try { + response = await fetch("https://api.openai.com/v1/responses", { + body: JSON.stringify({ + input: [ + { + content: + [ + "You convert RaidGuild published accounting report questions into one allowed JSON query plan.", + "Prefer concise text answers. Set chart to null unless the user explicitly asks for a chart, graph, visual, plot, or breakdown visualization.", + "Use limit 1 for winner questions such as 'which', 'who', 'most', 'highest', 'largest', or 'top subcontractor'.", + "Use revenue_by_month for questions about best revenue month, highest revenue month, monthly revenue, or revenue by month.", + "Use expenses_by_month for questions about highest expense month, most expensive month, monthly expenses, spending by month, or expenses by month.", + "Use the requested limit for top-N questions. If no limit is stated for a ranking/list question, use 5.", + "Use unsupported_report_question with unsupportedReason outside_report_data when the question asks about data not in this published report analysis surface, including member joins, new members, individual members, proposal details, raw transactions, wallet identity, or private/internal context.", + "Use unsupported_report_question with unsupportedReason small_talk for greetings, thanks, acknowledgements, or other conversational messages that are not report questions.", + "Use unsupported_report_question with unsupportedReason nonsense when the question is gibberish, a joke request, unrelated trivia, or not an accounting/report question.", + "Only use ranking, summary, and chart intents. Never request raw records, secrets, audit metadata, draft data, SQL, or database access.", + ].join(" "), + role: "developer", + }, + { + content: `Question: ${prompt}`, + role: "user", + }, + ], + max_output_tokens: 250, + model: getPlannerModel(), + store: false, + text: { + format: { + name: "report_assistant_plan", + schema: PLAN_SCHEMA, + strict: true, + type: "json_schema", + }, + }, + }), + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + method: "POST", + signal: controller.signal, + }); + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + throw new Error("OpenAI planner request timed out."); + } + + throw error; + } finally { + clearTimeout(timeout); + } + + if (!response.ok) { + const payload = await response.json().catch(() => null); + const message = + payload && + typeof payload === "object" && + "error" in payload && + payload.error && + typeof payload.error === "object" && + "message" in payload.error && + typeof payload.error.message === "string" + ? payload.error.message + : "OpenAI planner request failed."; + + throw new Error(message); + } + + const payload = (await response.json()) as Record; + const outputText = extractOutputText(payload); + + if (!outputText) { + throw new Error("OpenAI planner returned an empty response."); + } + + return validateReportAssistantPlan(JSON.parse(outputText)); +} diff --git a/src/lib/report-assistant/prompt-guard.ts b/src/lib/report-assistant/prompt-guard.ts new file mode 100644 index 0000000..97883be --- /dev/null +++ b/src/lib/report-assistant/prompt-guard.ts @@ -0,0 +1,32 @@ +import "server-only"; + +const MAX_PROMPT_LENGTH = 500; +const BLOCKED_PATTERNS = [ + /\bignore\b.*\b(instruction|system|developer|previous)\b/i, + /\bshow\b.*\b(secret|api key|private key|env|password)\b/i, + /\b(sql|database|schema|table)\b.*\b(query|dump|raw|select|insert|update|delete)\b/i, + /\bdecrypt(ed)?\b/i, + /\baudit[- ]only\b/i, +]; + +export function guardReportAssistantPrompt(prompt: unknown) { + if (typeof prompt !== "string") { + throw new Error("Ask a report question first."); + } + + const normalizedPrompt = prompt.replace(/\s+/g, " ").trim(); + + if (!normalizedPrompt) { + throw new Error("Ask a report question first."); + } + + if (normalizedPrompt.length > MAX_PROMPT_LENGTH) { + throw new Error("Ask a shorter report question."); + } + + if (BLOCKED_PATTERNS.some((pattern) => pattern.test(normalizedPrompt))) { + throw new Error("Ask a question about published report totals or rankings."); + } + + return normalizedPrompt; +} diff --git a/src/lib/report-assistant/query-executor.ts b/src/lib/report-assistant/query-executor.ts new file mode 100644 index 0000000..f587bbb --- /dev/null +++ b/src/lib/report-assistant/query-executor.ts @@ -0,0 +1,439 @@ +import "server-only"; + +import type { QuarterExportLedgerRow } from "@/lib/quarter-xlsx-export"; +import type { QuarterReportData } from "@/lib/quarter-report"; +import type { QuarterSummary } from "@/lib/quarters"; +import { + type ReportAssistantChart, + type ReportAssistantPlan, + type ReportAssistantResponse, + type ReportAssistantTableRow, +} from "@/lib/report-assistant/types"; +import { getCategoryLabel } from "@/lib/transaction-classification"; + +const REVENUE_CATEGORIES: NonNullable[] = [ + "raid_revenue", + "member_dues", +]; +const EXPENSE_CATEGORIES: NonNullable[] = [ + "provider_expense", + "ragequit", + "rip_expense", + "subcontractor_payout", +]; + +function toNumber(value: string | null | undefined) { + if (!value) { + return 0; + } + + const number = Number(value); + + return Number.isFinite(number) ? number : 0; +} + +function formatCurrency(value: number) { + return new Intl.NumberFormat("en-US", { + currency: "USD", + style: "currency", + }).format(value); +} + +function formatMonth(value: string) { + return new Intl.DateTimeFormat("en-US", { + month: "long", + timeZone: "UTC", + year: "numeric", + }).format(new Date(value)); +} + +function limitRows(rows: ReportAssistantTableRow[], limit: number | null) { + return rows.slice(0, limit ?? 5); +} + +function tableForRows(rows: ReportAssistantTableRow[]) { + return rows.length > 1 ? rows : []; +} + +function summarizeLedgerRows({ + category, + fallbackLabel, + getLabel, + ledgerRows, +}: { + category: NonNullable; + fallbackLabel: string; + getLabel: (row: QuarterExportLedgerRow) => string; + ledgerRows: QuarterExportLedgerRow[]; +}) { + const rows = new Map(); + + for (const ledgerRow of ledgerRows) { + if (ledgerRow.category !== category) { + continue; + } + + const label = getLabel(ledgerRow) || fallbackLabel; + const row = rows.get(label) ?? { entries: 0, label, value: 0 }; + + row.entries = (row.entries ?? 0) + 1; + row.value += toNumber(ledgerRow.usdAmount); + rows.set(label, row); + } + + return Array.from(rows.values()).sort((left, right) => { + if (left.value !== right.value) { + return right.value - left.value; + } + + return left.label.localeCompare(right.label); + }); +} + +function getClientFromRaidLabel(raidLabel: string) { + const match = /\(([^()]*)\)\s*$/.exec(raidLabel); + + return match?.[1]?.trim() || raidLabel || "Unlinked Client"; +} + +function chartFromRows({ + plan, + rows, + title, +}: { + plan: ReportAssistantPlan; + rows: ReportAssistantTableRow[]; + title: string; +}): ReportAssistantChart | null { + if ((plan.chart !== "bar" && plan.chart !== "pie") || rows.length < 2) { + return null; + } + + return { + rows, + title, + type: plan.chart, + }; +} + +function makeResponse({ + answer, + chart, + grouping, + metric, + plan, + quarter, + table, +}: { + answer: string; + chart: ReportAssistantChart | null; + grouping: string; + metric: string; + plan: ReportAssistantPlan; + quarter: QuarterSummary; + table: ReportAssistantTableRow[]; +}): ReportAssistantResponse { + return { + answer, + chart, + plan, + provenance: { + grouping, + lastPublishedAt: quarter.publishedAt, + metric, + quarter: quarter.label, + }, + table, + }; +} + +function summarizeTopRow(rows: ReportAssistantTableRow[]) { + const [topRow] = rows; + + if (!topRow) { + return "No matching published report rows were found."; + } + + return `${topRow.label} leads with ${formatCurrency(topRow.value)}.`; +} + +function summarizeTopMonthByRevenue(rows: ReportAssistantTableRow[]) { + const [topRow] = rows; + + if (!topRow) { + return "No published report revenue rows were found for this quarter."; + } + + return `${topRow.label} had the highest revenue this quarter at ${formatCurrency(topRow.value)}.`; +} + +function summarizeTopMonthByExpenses(rows: ReportAssistantTableRow[]) { + const [topRow] = rows; + + if (!topRow) { + return "No published report expense rows were found for this quarter."; + } + + return `${topRow.label} had the most expenses this quarter at ${formatCurrency(topRow.value)}.`; +} + +function summarizeCategoriesByMonth({ + categories, + ledgerRows, + sortByValue, +}: { + categories: NonNullable[]; + ledgerRows: QuarterExportLedgerRow[]; + sortByValue: boolean; +}) { + const rows = new Map(); + + for (const ledgerRow of ledgerRows) { + if (!ledgerRow.category || !categories.includes(ledgerRow.category)) { + continue; + } + + const occurredAt = new Date(ledgerRow.occurredAt); + + if (Number.isNaN(occurredAt.getTime())) { + continue; + } + + const monthKey = `${occurredAt.getUTCFullYear()}-${String( + occurredAt.getUTCMonth() + 1, + ).padStart(2, "0")}-01T00:00:00.000Z`; + const label = formatMonth(monthKey); + const row = rows.get(monthKey) ?? { entries: 0, label, value: 0 }; + + row.entries = (row.entries ?? 0) + 1; + row.value += toNumber(ledgerRow.usdAmount); + rows.set(monthKey, row); + } + + return Array.from(rows.entries()) + .map(([monthKey, row]) => ({ monthKey, ...row })) + .sort((left, right) => { + if (sortByValue && left.value !== right.value) { + return right.value - left.value; + } + + return left.monthKey.localeCompare(right.monthKey); + }) + .map((row) => ({ + entries: row.entries, + label: row.label, + value: row.value, + })); +} + +export function executeReportAssistantPlan({ + plan, + quarter, + report, +}: { + plan: ReportAssistantPlan; + quarter: QuarterSummary; + report: QuarterReportData; +}): ReportAssistantResponse { + if (plan.intent === "unsupported_report_question") { + return makeResponse({ + answer: + plan.unsupportedReason === "small_talk" + ? "Ask me a question about this published report, such as revenue, expenses, raids, clients, subcontractors, providers, or report totals." + : plan.unsupportedReason === "nonsense" + ? "I can only answer questions about the published quarter report, such as revenue, expenses, raids, clients, subcontractors, providers, and report totals." + : "This published report does not include the data needed to answer that. I can answer questions about report totals, raid revenue, client revenue, subcontractor payouts, provider expenses, and category totals.", + chart: null, + grouping: "Unsupported", + metric: "Report scope", + plan, + quarter, + table: [], + }); + } + + if (plan.intent === "quarter_summary") { + const rows: ReportAssistantTableRow[] = [ + { label: "Revenue", value: report.metrics.revenue }, + { label: "Expenses", value: report.metrics.expenses }, + { label: "Net", value: report.metrics.net }, + { label: "Spoils Received", value: report.metrics.spoilsReceived }, + ]; + + return makeResponse({ + answer: `${quarter.label} shows ${formatCurrency(report.metrics.revenue)} in revenue, ${formatCurrency(report.metrics.expenses)} in expenses, ${formatCurrency(report.metrics.net)} net, and ${formatCurrency(report.metrics.spoilsReceived)} in spoils received.`, + chart: chartFromRows({ plan, rows, title: "Quarter Summary" }), + grouping: "Summary metrics", + metric: "USD totals", + plan, + quarter, + table: rows, + }); + } + + if (plan.intent === "top_raids_by_revenue") { + const rows = limitRows( + report.topRaids.map((row) => ({ + entries: row.entries, + label: row.label, + value: row.totalUsd, + })), + plan.limit, + ); + + return makeResponse({ + answer: summarizeTopRow(rows), + chart: chartFromRows({ plan, rows, title: "Top Raids by Revenue" }), + grouping: "Raid", + metric: "Raid revenue", + plan, + quarter, + table: tableForRows(rows), + }); + } + + if (plan.intent === "top_clients_by_revenue") { + const rows = limitRows( + summarizeLedgerRows({ + category: "raid_revenue", + fallbackLabel: "Unlinked Client", + getLabel: (row) => getClientFromRaidLabel(row.raid), + ledgerRows: report.ledgerRows, + }), + plan.limit, + ); + + return makeResponse({ + answer: summarizeTopRow(rows), + chart: chartFromRows({ plan, rows, title: "Top Clients by Revenue" }), + grouping: "Client", + metric: "Raid revenue", + plan, + quarter, + table: tableForRows(rows), + }); + } + + if (plan.intent === "top_subcontractors_by_payout") { + const rows = limitRows( + summarizeLedgerRows({ + category: "subcontractor_payout", + fallbackLabel: "Unlinked Subcontractor", + getLabel: (row) => row.counterparty, + ledgerRows: report.ledgerRows, + }), + plan.limit, + ); + + return makeResponse({ + answer: summarizeTopRow(rows), + chart: chartFromRows({ plan, rows, title: "Top Subcontractor Payouts" }), + grouping: "Subcontractor", + metric: "Subcontractor payouts", + plan, + quarter, + table: tableForRows(rows), + }); + } + + if (plan.intent === "top_providers_by_expense") { + const rows = limitRows( + report.providerExpenses.map((row) => ({ + entries: row.entries, + label: row.label, + value: row.totalUsd, + })), + plan.limit, + ); + + return makeResponse({ + answer: summarizeTopRow(rows), + chart: chartFromRows({ plan, rows, title: "Top Provider Expenses" }), + grouping: "Provider", + metric: "Provider expenses", + plan, + quarter, + table: tableForRows(rows), + }); + } + + if (plan.intent === "revenue_by_month") { + const rows = limitRows( + summarizeCategoriesByMonth({ + categories: REVENUE_CATEGORIES, + ledgerRows: report.ledgerRows, + sortByValue: plan.limit === 1, + }), + plan.limit, + ); + + return makeResponse({ + answer: summarizeTopMonthByRevenue(rows), + chart: chartFromRows({ plan, rows, title: "Revenue by Month" }), + grouping: "Month", + metric: "Revenue", + plan, + quarter, + table: tableForRows(rows), + }); + } + + if (plan.intent === "expenses_by_month") { + const rows = limitRows( + summarizeCategoriesByMonth({ + categories: EXPENSE_CATEGORIES, + ledgerRows: report.ledgerRows, + sortByValue: plan.limit === 1, + }), + plan.limit, + ); + + return makeResponse({ + answer: summarizeTopMonthByExpenses(rows), + chart: chartFromRows({ plan, rows, title: "Expenses by Month" }), + grouping: "Month", + metric: "Expenses", + plan, + quarter, + table: tableForRows(rows), + }); + } + + const rows = limitRows( + EXPENSE_CATEGORIES.map((category) => + report.ledgerRows.reduce( + (summary, row) => { + if (row.category !== category) { + return summary; + } + + summary.entries = (summary.entries ?? 0) + 1; + summary.value += toNumber(row.usdAmount); + return summary; + }, + { + entries: 0, + label: getCategoryLabel(category), + value: 0, + }, + ), + ) + .filter((row) => row.value > 0) + .sort((left, right) => right.value - left.value), + plan.limit, + ); + + return makeResponse({ + answer: summarizeTopRow(rows), + chart: chartFromRows({ + plan, + rows, + title: "Expenses by Category", + }), + grouping: "Expense category", + metric: "Expense USD totals", + plan, + quarter, + table: rows, + }); +} diff --git a/src/lib/report-assistant/query-validator.ts b/src/lib/report-assistant/query-validator.ts new file mode 100644 index 0000000..e0d3802 --- /dev/null +++ b/src/lib/report-assistant/query-validator.ts @@ -0,0 +1,73 @@ +import "server-only"; + +import { + REPORT_ASSISTANT_INTENTS, + type ReportAssistantChartType, + type ReportAssistantIntent, + type ReportAssistantPlan, + type ReportAssistantUnsupportedReason, +} from "@/lib/report-assistant/types"; + +const CHART_TYPES = ["bar", "pie", "table", null] as const; +const UNSUPPORTED_REASONS = [ + "outside_report_data", + "small_talk", + "nonsense", + null, +] as const; + +function isIntent(value: unknown): value is ReportAssistantIntent { + return ( + typeof value === "string" && + REPORT_ASSISTANT_INTENTS.includes(value as ReportAssistantIntent) + ); +} + +function isChartType(value: unknown): value is ReportAssistantChartType { + return CHART_TYPES.includes(value as ReportAssistantChartType); +} + +function isUnsupportedReason( + value: unknown, +): value is ReportAssistantUnsupportedReason { + return UNSUPPORTED_REASONS.includes(value as ReportAssistantUnsupportedReason); +} + +function normalizeLimit(value: unknown) { + if (value === null || value === undefined) { + return null; + } + + if (!Number.isInteger(value) || typeof value !== "number") { + throw new Error("Unsupported report query limit."); + } + + return Math.min(Math.max(value, 1), 10); +} + +export function validateReportAssistantPlan(value: unknown): ReportAssistantPlan { + if (!value || typeof value !== "object") { + throw new Error("Unsupported report query."); + } + + const candidate = value as Record; + + if (!isIntent(candidate.intent)) { + throw new Error("Unsupported report question."); + } + + if (!isChartType(candidate.chart)) { + throw new Error("Unsupported chart request."); + } + + if (!isUnsupportedReason(candidate.unsupportedReason)) { + throw new Error("Unsupported report question."); + } + + return { + chart: candidate.chart, + intent: candidate.intent, + limit: normalizeLimit(candidate.limit), + unsupportedReason: candidate.unsupportedReason, + }; +} diff --git a/src/lib/report-assistant/rate-limit.ts b/src/lib/report-assistant/rate-limit.ts new file mode 100644 index 0000000..3ba7eac --- /dev/null +++ b/src/lib/report-assistant/rate-limit.ts @@ -0,0 +1,48 @@ +import "server-only"; + +import { sql } from "drizzle-orm"; + +import { getDb } from "@/db"; + +export class ReportAssistantRateLimitError extends Error { + constructor() { + super("Report assistant rate limit reached. Try again in a minute."); + this.name = "ReportAssistantRateLimitError"; + } +} + +export async function checkReportAssistantRateLimit({ + key, + maxRequests, + windowMs, +}: { + key: string; + maxRequests: number; + windowMs: number; +}) { + const resetAt = new Date(Date.now() + windowMs); + const result = await getDb().execute<{ + count: number; + reset_at: Date; + }>(sql` + insert into report_assistant_rate_limits (key, count, reset_at) + values (${key}, 1, ${resetAt}) + on conflict (key) do update + set + count = case + when report_assistant_rate_limits.reset_at <= now() then 1 + else report_assistant_rate_limits.count + 1 + end, + reset_at = case + when report_assistant_rate_limits.reset_at <= now() then ${resetAt} + else report_assistant_rate_limits.reset_at + end, + updated_at = now() + returning count, reset_at + `); + const [row] = result.rows; + + if (Number(row?.count ?? 0) > maxRequests) { + throw new ReportAssistantRateLimitError(); + } +} diff --git a/src/lib/report-assistant/types.ts b/src/lib/report-assistant/types.ts new file mode 100644 index 0000000..f4861aa --- /dev/null +++ b/src/lib/report-assistant/types.ts @@ -0,0 +1,54 @@ +export const REPORT_ASSISTANT_INTENTS = [ + "quarter_summary", + "top_raids_by_revenue", + "top_clients_by_revenue", + "top_subcontractors_by_payout", + "top_providers_by_expense", + "expenses_by_category", + "revenue_by_month", + "expenses_by_month", + "unsupported_report_question", +] as const; + +export type ReportAssistantIntent = (typeof REPORT_ASSISTANT_INTENTS)[number]; + +export type ReportAssistantChartType = "bar" | "pie" | "table" | null; +export type ReportAssistantUnsupportedReason = + | "outside_report_data" + | "small_talk" + | "nonsense" + | null; + +export type ReportAssistantPlan = { + chart: ReportAssistantChartType; + intent: ReportAssistantIntent; + limit: number | null; + unsupportedReason: ReportAssistantUnsupportedReason; +}; + +export type ReportAssistantTableRow = { + entries?: number; + label: string; + value: number; +}; + +export type ReportAssistantChart = { + rows: ReportAssistantTableRow[]; + title: string; + type: Exclude; +}; + +export type ReportAssistantProvenance = { + grouping: string; + lastPublishedAt: string | null; + metric: string; + quarter: string; +}; + +export type ReportAssistantResponse = { + answer: string; + chart: ReportAssistantChart | null; + plan: ReportAssistantPlan; + provenance: ReportAssistantProvenance; + table: ReportAssistantTableRow[]; +};