From 21a82da3f3d3d5dc4ed7ae4746af4b376a85233a Mon Sep 17 00:00:00 2001 From: Rajat Saxena Date: Thu, 29 Oct 2026 17:47:33 +0530 Subject: [PATCH 1/5] feat(api): delete an account with its sole-owned organizations and contacts' data (#134) GET /account/deletion previews what happens to each organization and what blocks the delete; POST /account/deletion/code emails a confirmation code; DELETE /account takes the typed email and code. Dashboard sessions only, with the billing actions' origin and CSRF checks. Organizations the person is the last owner of are deleted through deleteOrganization (MediaLit files included) and their contacts' personal data erased; suppression hashes stay. Shared organizations are only left. Sessions, sign-in methods, OAuth tokens, consents and codes are deleted and the user row becomes an anonymous tombstone (user.deleted_at), which auth refuses. Blocked by a running subscription, an open checkout, other members depending on them as last owner, or paying for a shared organization. --- apps/api/drizzle/0029_account_delete.sql | 1 + apps/api/drizzle/meta/0029_snapshot.json | 11651 +++++++++++++++++ apps/api/drizzle/meta/_journal.json | 7 + apps/api/src/account/delete.routes.test.ts | 547 + apps/api/src/account/delete.ts | 266 + apps/api/src/account/erase.ts | 122 + apps/api/src/account/routes.ts | 131 + apps/api/src/app.ts | 2 + apps/api/src/auth/better-auth.ts | 35 +- apps/api/src/auth/resolve-auth.ts | 5 +- apps/api/src/billing/security.ts | 13 +- apps/api/src/db/schema-core.ts | 4 + apps/api/src/delivery-feedback/retention.ts | 28 +- apps/api/src/observability/instance.ts | 4 + apps/api/src/organization/queries.ts | 60 +- packages/api-contract/src/contract.ts | 50 + packages/api-contract/src/index.ts | 1 + packages/api-contract/src/mcp-parity.ts | 5 + packages/api-contract/src/openapi.ts | 1 + packages/api-contract/src/schemas/account.ts | 40 + 20 files changed, 12936 insertions(+), 37 deletions(-) create mode 100644 apps/api/drizzle/0029_account_delete.sql create mode 100644 apps/api/drizzle/meta/0029_snapshot.json create mode 100644 apps/api/src/account/delete.routes.test.ts create mode 100644 apps/api/src/account/delete.ts create mode 100644 apps/api/src/account/erase.ts create mode 100644 apps/api/src/account/routes.ts create mode 100644 packages/api-contract/src/schemas/account.ts diff --git a/apps/api/drizzle/0029_account_delete.sql b/apps/api/drizzle/0029_account_delete.sql new file mode 100644 index 00000000..88d3000e --- /dev/null +++ b/apps/api/drizzle/0029_account_delete.sql @@ -0,0 +1 @@ +ALTER TABLE "user" ADD COLUMN "deleted_at" timestamp with time zone; \ No newline at end of file diff --git a/apps/api/drizzle/meta/0029_snapshot.json b/apps/api/drizzle/meta/0029_snapshot.json new file mode 100644 index 00000000..347cecb9 --- /dev/null +++ b/apps/api/drizzle/meta/0029_snapshot.json @@ -0,0 +1,11651 @@ +{ + "id": "ec728022-3cfd-4575-91ce-044b33457377", + "prevId": "e8de4127-8e8e-466d-8b61-06a39e87feb3", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.abuse_flags": { + "name": "abuse_flags", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "flag_id": { + "name": "flag_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "sequence_email_id": { + "name": "sequence_email_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resolved_by": { + "name": "resolved_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resolution_note": { + "name": "resolution_note", + "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": { + "abuse_flags_status_created_at_idx": { + "name": "abuse_flags_status_created_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "abuse_flags_organization_kind_status_idx": { + "name": "abuse_flags_organization_kind_status_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "abuse_flags_organization_id_organizations_id_fk": { + "name": "abuse_flags_organization_id_organizations_id_fk", + "tableFrom": "abuse_flags", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "abuse_flags_team_id_teams_id_fk": { + "name": "abuse_flags_team_id_teams_id_fk", + "tableFrom": "abuse_flags", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "abuse_flags_sequence_email_id_sequence_emails_id_fk": { + "name": "abuse_flags_sequence_email_id_sequence_emails_id_fk", + "tableFrom": "abuse_flags", + "tableTo": "sequence_emails", + "columnsFrom": [ + "sequence_email_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "abuse_flags_flag_id_unique": { + "name": "abuse_flags_flag_id_unique", + "nullsNotDistinct": false, + "columns": [ + "flag_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "abuse_flags_flag_id_check": { + "name": "abuse_flags_flag_id_check", + "value": "\"abuse_flags\".\"flag_id\" ~ '^abf_'" + }, + "abuse_flags_kind_check": { + "name": "abuse_flags_kind_check", + "value": "\"abuse_flags\".\"kind\" IN ('signup', 'content', 'import', 'broadcast')" + }, + "abuse_flags_severity_check": { + "name": "abuse_flags_severity_check", + "value": "\"abuse_flags\".\"severity\" IN ('low', 'medium', 'high')" + }, + "abuse_flags_status_check": { + "name": "abuse_flags_status_check", + "value": "\"abuse_flags\".\"status\" IN ('open', 'released', 'confirmed')" + } + }, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "auth_account_user_id_idx": { + "name": "auth_account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_account_issuer_account_id_uidx": { + "name": "auth_account_issuer_account_id_uidx", + "columns": [ + { + "expression": "issuer", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contact_custom_field_values": { + "name": "contact_custom_field_values", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value_type": { + "name": "value_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value_text": { + "name": "value_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "value_number": { + "name": "value_number", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "value_boolean": { + "name": "value_boolean", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "value_date": { + "name": "value_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "contact_custom_field_values_contact_key_idx": { + "name": "contact_custom_field_values_contact_key_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "contact_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "contact_custom_field_values_text_lookup_idx": { + "name": "contact_custom_field_values_text_lookup_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "value_text", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "contact_custom_field_values_number_lookup_idx": { + "name": "contact_custom_field_values_number_lookup_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "value_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "contact_custom_field_values_boolean_lookup_idx": { + "name": "contact_custom_field_values_boolean_lookup_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "value_boolean", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "contact_custom_field_values_date_lookup_idx": { + "name": "contact_custom_field_values_date_lookup_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "value_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "contact_custom_field_values_team_id_teams_id_fk": { + "name": "contact_custom_field_values_team_id_teams_id_fk", + "tableFrom": "contact_custom_field_values", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "contact_custom_field_values_contact_id_contacts_id_fk": { + "name": "contact_custom_field_values_contact_id_contacts_id_fk", + "tableFrom": "contact_custom_field_values", + "tableTo": "contacts", + "columnsFrom": [ + "contact_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contact_event_matches": { + "name": "contact_event_matches", + "schema": "", + "columns": { + "contact_event_id": { + "name": "contact_event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sequence_id": { + "name": "sequence_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "enrolled": { + "name": "enrolled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "contact_event_matches_contact_event_id_contact_events_id_fk": { + "name": "contact_event_matches_contact_event_id_contact_events_id_fk", + "tableFrom": "contact_event_matches", + "tableTo": "contact_events", + "columnsFrom": [ + "contact_event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "contact_event_matches_sequence_id_sequences_id_fk": { + "name": "contact_event_matches_sequence_id_sequences_id_fk", + "tableFrom": "contact_event_matches", + "tableTo": "sequences", + "columnsFrom": [ + "sequence_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "contact_event_matches_contact_event_id_sequence_id_pk": { + "name": "contact_event_matches_contact_event_id_sequence_id_pk", + "columns": [ + "contact_event_id", + "sequence_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contact_events": { + "name": "contact_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "properties": { + "name": "properties", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "backfill": { + "name": "backfill", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "triggers": { + "name": "triggers", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "contact_events_team_id_idempotency_key_idx": { + "name": "contact_events_team_id_idempotency_key_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"contact_events\".\"idempotency_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "contact_events_contact_id_occurred_at_idx": { + "name": "contact_events_contact_id_occurred_at_idx", + "columns": [ + { + "expression": "contact_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "contact_events_unprocessed_idx": { + "name": "contact_events_unprocessed_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"contact_events\".\"processed_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "contact_events_team_id_teams_id_fk": { + "name": "contact_events_team_id_teams_id_fk", + "tableFrom": "contact_events", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "contact_events_contact_id_contacts_id_fk": { + "name": "contact_events_contact_id_contacts_id_fk", + "tableFrom": "contact_events", + "tableTo": "contacts", + "columnsFrom": [ + "contact_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "contact_events_event_id_unique": { + "name": "contact_events_event_id_unique", + "nullsNotDistinct": false, + "columns": [ + "event_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "contact_events_event_id_check": { + "name": "contact_events_event_id_check", + "value": "\"contact_events\".\"event_id\" ~ '^evt_'" + } + }, + "isRLSEnabled": false + }, + "public.contacts": { + "name": "contacts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subscribed": { + "name": "subscribed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "custom_fields": { + "name": "custom_fields", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "unsubscribe_token": { + "name": "unsubscribe_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "contacts_team_id_email_idx": { + "name": "contacts_team_id_email_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "contacts_team_id_teams_id_fk": { + "name": "contacts_team_id_teams_id_fk", + "tableFrom": "contacts", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "contacts_contact_id_unique": { + "name": "contacts_contact_id_unique", + "nullsNotDistinct": false, + "columns": [ + "contact_id" + ] + }, + "contacts_unsubscribe_token_unique": { + "name": "contacts_unsubscribe_token_unique", + "nullsNotDistinct": false, + "columns": [ + "unsubscribe_token" + ] + } + }, + "policies": {}, + "checkConstraints": { + "contacts_contact_id_check": { + "name": "contacts_contact_id_check", + "value": "\"contacts\".\"contact_id\" ~ '^cnt_'" + } + }, + "isRLSEnabled": false + }, + "public.email_content_versions": { + "name": "email_content_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "version_id": { + "name": "version_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sequence_email_id": { + "name": "sequence_email_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "email_content_versions_email_hash_idx": { + "name": "email_content_versions_email_hash_idx", + "columns": [ + { + "expression": "sequence_email_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "content_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_content_versions_team_id_teams_id_fk": { + "name": "email_content_versions_team_id_teams_id_fk", + "tableFrom": "email_content_versions", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_content_versions_sequence_email_id_sequence_emails_id_fk": { + "name": "email_content_versions_sequence_email_id_sequence_emails_id_fk", + "tableFrom": "email_content_versions", + "tableTo": "sequence_emails", + "columnsFrom": [ + "sequence_email_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "email_content_versions_version_id_unique": { + "name": "email_content_versions_version_id_unique", + "nullsNotDistinct": false, + "columns": [ + "version_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "email_content_versions_version_id_check": { + "name": "email_content_versions_version_id_check", + "value": "\"email_content_versions\".\"version_id\" ~ '^ver_'" + } + }, + "isRLSEnabled": false + }, + "public.email_deliveries": { + "name": "email_deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sequence_id": { + "name": "sequence_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email_id": { + "name": "email_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "content_version_id": { + "name": "content_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "email_deliveries_team_id_teams_id_fk": { + "name": "email_deliveries_team_id_teams_id_fk", + "tableFrom": "email_deliveries", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_deliveries_sequence_id_sequences_id_fk": { + "name": "email_deliveries_sequence_id_sequences_id_fk", + "tableFrom": "email_deliveries", + "tableTo": "sequences", + "columnsFrom": [ + "sequence_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_deliveries_contact_id_contacts_id_fk": { + "name": "email_deliveries_contact_id_contacts_id_fk", + "tableFrom": "email_deliveries", + "tableTo": "contacts", + "columnsFrom": [ + "contact_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_deliveries_email_id_sequence_emails_id_fk": { + "name": "email_deliveries_email_id_sequence_emails_id_fk", + "tableFrom": "email_deliveries", + "tableTo": "sequence_emails", + "columnsFrom": [ + "email_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_deliveries_content_version_id_email_content_versions_id_fk": { + "name": "email_deliveries_content_version_id_email_content_versions_id_fk", + "tableFrom": "email_deliveries", + "tableTo": "email_content_versions", + "columnsFrom": [ + "content_version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_delivery_events": { + "name": "email_delivery_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "receipt_id": { + "name": "receipt_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "outbound_message_id": { + "name": "outbound_message_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_event_key": { + "name": "provider_event_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_message_id": { + "name": "provider_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "recipient_email": { + "name": "recipient_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "normalized_recipient": { + "name": "normalized_recipient", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bounce_class": { + "name": "bounce_class", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "smtp_code": { + "name": "smtp_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "enhanced_status_code": { + "name": "enhanced_status_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "remote_mta": { + "name": "remote_mta", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "received_at": { + "name": "received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "email_delivery_events_connection_id_provider_event_key_idx": { + "name": "email_delivery_events_connection_id_provider_event_key_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_delivery_events_team_id_occurred_at_idx": { + "name": "email_delivery_events_team_id_occurred_at_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_delivery_events_outbound_message_id_idx": { + "name": "email_delivery_events_outbound_message_id_idx", + "columns": [ + { + "expression": "outbound_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_delivery_events_receipt_id_esp_webhook_receipts_id_fk": { + "name": "email_delivery_events_receipt_id_esp_webhook_receipts_id_fk", + "tableFrom": "email_delivery_events", + "tableTo": "esp_webhook_receipts", + "columnsFrom": [ + "receipt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_delivery_events_connection_id_esp_feedback_connections_id_fk": { + "name": "email_delivery_events_connection_id_esp_feedback_connections_id_fk", + "tableFrom": "email_delivery_events", + "tableTo": "esp_feedback_connections", + "columnsFrom": [ + "connection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_delivery_events_team_id_teams_id_fk": { + "name": "email_delivery_events_team_id_teams_id_fk", + "tableFrom": "email_delivery_events", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_delivery_events_outbound_message_id_outbound_messages_id_fk": { + "name": "email_delivery_events_outbound_message_id_outbound_messages_id_fk", + "tableFrom": "email_delivery_events", + "tableTo": "outbound_messages", + "columnsFrom": [ + "outbound_message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "email_delivery_events_event_id_unique": { + "name": "email_delivery_events_event_id_unique", + "nullsNotDistinct": false, + "columns": [ + "event_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "email_delivery_events_event_id_check": { + "name": "email_delivery_events_event_id_check", + "value": "\"email_delivery_events\".\"event_id\" ~ '^(evt|dev)_'" + } + }, + "isRLSEnabled": false + }, + "public.email_events": { + "name": "email_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sequence_id": { + "name": "sequence_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email_id": { + "name": "email_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "link_index": { + "name": "link_index", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "bounce_type": { + "name": "bounce_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bounce_reason": { + "name": "bounce_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "email_events_team_id_teams_id_fk": { + "name": "email_events_team_id_teams_id_fk", + "tableFrom": "email_events", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_events_sequence_id_sequences_id_fk": { + "name": "email_events_sequence_id_sequences_id_fk", + "tableFrom": "email_events", + "tableTo": "sequences", + "columnsFrom": [ + "sequence_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_events_contact_id_contacts_id_fk": { + "name": "email_events_contact_id_contacts_id_fk", + "tableFrom": "email_events", + "tableTo": "contacts", + "columnsFrom": [ + "contact_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_events_email_id_sequence_emails_id_fk": { + "name": "email_events_email_id_sequence_emails_id_fk", + "tableFrom": "email_events", + "tableTo": "sequence_emails", + "columnsFrom": [ + "email_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_suppression_actions": { + "name": "email_suppression_actions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "suppression_id": { + "name": "suppression_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_event_id": { + "name": "source_event_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_type": { + "name": "actor_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "explanation": { + "name": "explanation", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_suppression_actions_suppression_id_created_at_idx": { + "name": "email_suppression_actions_suppression_id_created_at_idx", + "columns": [ + { + "expression": "suppression_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_suppression_actions_team_id_teams_id_fk": { + "name": "email_suppression_actions_team_id_teams_id_fk", + "tableFrom": "email_suppression_actions", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_suppression_actions_suppression_id_email_suppressions_id_fk": { + "name": "email_suppression_actions_suppression_id_email_suppressions_id_fk", + "tableFrom": "email_suppression_actions", + "tableTo": "email_suppressions", + "columnsFrom": [ + "suppression_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_suppression_actions_source_event_id_email_delivery_events_id_fk": { + "name": "email_suppression_actions_source_event_id_email_delivery_events_id_fk", + "tableFrom": "email_suppression_actions", + "tableTo": "email_delivery_events", + "columnsFrom": [ + "source_event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "email_suppression_actions_actor_user_id_user_id_fk": { + "name": "email_suppression_actions_actor_user_id_user_id_fk", + "tableFrom": "email_suppression_actions", + "tableTo": "user", + "columnsFrom": [ + "actor_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_suppressions": { + "name": "email_suppressions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "suppression_id": { + "name": "suppression_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "recipient_email": { + "name": "recipient_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "normalized_recipient": { + "name": "normalized_recipient", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "recipient_hash": { + "name": "recipient_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash_key_version": { + "name": "hash_key_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_event_id": { + "name": "source_event_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "first_suppressed_at": { + "name": "first_suppressed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_suppressed_at": { + "name": "last_suppressed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "released_at": { + "name": "released_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "released_by": { + "name": "released_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "release_reason": { + "name": "release_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "email_suppressions_team_id_recipient_hash_idx": { + "name": "email_suppressions_team_id_recipient_hash_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "recipient_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_suppressions_team_id_active_idx": { + "name": "email_suppressions_team_id_active_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_suppressions_team_id_teams_id_fk": { + "name": "email_suppressions_team_id_teams_id_fk", + "tableFrom": "email_suppressions", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_suppressions_source_event_id_email_delivery_events_id_fk": { + "name": "email_suppressions_source_event_id_email_delivery_events_id_fk", + "tableFrom": "email_suppressions", + "tableTo": "email_delivery_events", + "columnsFrom": [ + "source_event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "email_suppressions_released_by_user_id_fk": { + "name": "email_suppressions_released_by_user_id_fk", + "tableFrom": "email_suppressions", + "tableTo": "user", + "columnsFrom": [ + "released_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "email_suppressions_suppression_id_unique": { + "name": "email_suppressions_suppression_id_unique", + "nullsNotDistinct": false, + "columns": [ + "suppression_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "email_suppressions_suppression_id_check": { + "name": "email_suppressions_suppression_id_check", + "value": "\"email_suppressions\".\"suppression_id\" ~ '^sup_'" + } + }, + "isRLSEnabled": false + }, + "public.email_templates": { + "name": "email_templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "template_id": { + "name": "template_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "purpose": { + "name": "purpose", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'marketing'" + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "email_templates_team_id_title_idx": { + "name": "email_templates_team_id_title_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_templates_team_id_teams_id_fk": { + "name": "email_templates_team_id_teams_id_fk", + "tableFrom": "email_templates", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "email_templates_template_id_unique": { + "name": "email_templates_template_id_unique", + "nullsNotDistinct": false, + "columns": [ + "template_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "email_templates_template_id_check": { + "name": "email_templates_template_id_check", + "value": "\"email_templates\".\"template_id\" ~ '^tpl_'" + }, + "email_templates_purpose_check": { + "name": "email_templates_purpose_check", + "value": "\"email_templates\".\"purpose\" in ('marketing', 'transactional')" + } + }, + "isRLSEnabled": false + }, + "public.esp_config_team_grants": { + "name": "esp_config_team_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "grant_id": { + "name": "grant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "esp_config_id": { + "name": "esp_config_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "drain_until": { + "name": "drain_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reply_to": { + "name": "reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "daily_limit": { + "name": "daily_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "monthly_limit": { + "name": "monthly_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_by_type": { + "name": "created_by_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_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": { + "esp_config_team_grants_non_revoked_team_idx": { + "name": "esp_config_team_grants_non_revoked_team_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"esp_config_team_grants\".\"status\" <> 'revoked'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "esp_config_team_grants_team_organization_fk": { + "name": "esp_config_team_grants_team_organization_fk", + "tableFrom": "esp_config_team_grants", + "tableTo": "teams", + "columnsFrom": [ + "team_id", + "organization_id" + ], + "columnsTo": [ + "id", + "organization_id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "esp_config_team_grants_esp_organization_fk": { + "name": "esp_config_team_grants_esp_organization_fk", + "tableFrom": "esp_config_team_grants", + "tableTo": "esp_configs", + "columnsFrom": [ + "esp_config_id", + "organization_id" + ], + "columnsTo": [ + "id", + "organization_id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "esp_config_team_grants_grant_id_unique": { + "name": "esp_config_team_grants_grant_id_unique", + "nullsNotDistinct": false, + "columns": [ + "grant_id" + ] + }, + "esp_config_team_grants_id_organization_id_unique": { + "name": "esp_config_team_grants_id_organization_id_unique", + "nullsNotDistinct": false, + "columns": [ + "id", + "organization_id" + ] + }, + "esp_config_team_grants_id_team_esp_unique": { + "name": "esp_config_team_grants_id_team_esp_unique", + "nullsNotDistinct": false, + "columns": [ + "id", + "team_id", + "esp_config_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "esp_config_team_grants_public_id_check": { + "name": "esp_config_team_grants_public_id_check", + "value": "\"esp_config_team_grants\".\"grant_id\" ~ '^egr_'" + }, + "esp_config_team_grants_status_check": { + "name": "esp_config_team_grants_status_check", + "value": "\"esp_config_team_grants\".\"status\" IN ('active', 'draining', 'suspended', 'revoked')" + }, + "esp_config_team_grants_limit_check": { + "name": "esp_config_team_grants_limit_check", + "value": "(\"esp_config_team_grants\".\"daily_limit\" IS NULL OR \"esp_config_team_grants\".\"daily_limit\" >= 0)\n AND (\"esp_config_team_grants\".\"monthly_limit\" IS NULL OR \"esp_config_team_grants\".\"monthly_limit\" >= 0)" + }, + "esp_config_team_grants_created_by_type_check": { + "name": "esp_config_team_grants_created_by_type_check", + "value": "\"esp_config_team_grants\".\"created_by_type\" IN ('user', 'organization_key', 'system')" + } + }, + "isRLSEnabled": false + }, + "public.esp_configs": { + "name": "esp_configs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "esp_id": { + "name": "esp_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_scope": { + "name": "owner_scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'smtp'" + }, + "host": { + "name": "host", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 587 + }, + "secure": { + "name": "secure", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_secret": { + "name": "encrypted_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reply_to": { + "name": "reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_send_rate": { + "name": "max_send_rate", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "daily_send_limit": { + "name": "daily_send_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "warmup_target_daily": { + "name": "warmup_target_daily", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "warmup_started_at": { + "name": "warmup_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "dkim_selector": { + "name": "dkim_selector", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "secret_version": { + "name": "secret_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "last_tested_at": { + "name": "last_tested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_test_status": { + "name": "last_test_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_test_error": { + "name": "last_test_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "activated_at": { + "name": "activated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "drain_until": { + "name": "drain_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "retired_at": { + "name": "retired_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": { + "esp_configs_organization_id_idx": { + "name": "esp_configs_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "esp_configs_team_id_idx": { + "name": "esp_configs_team_id_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "esp_configs_organization_id_organizations_id_fk": { + "name": "esp_configs_organization_id_organizations_id_fk", + "tableFrom": "esp_configs", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "esp_configs_team_id_teams_id_fk": { + "name": "esp_configs_team_id_teams_id_fk", + "tableFrom": "esp_configs", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "esp_configs_esp_id_unique": { + "name": "esp_configs_esp_id_unique", + "nullsNotDistinct": false, + "columns": [ + "esp_id" + ] + }, + "esp_configs_id_organization_id_unique": { + "name": "esp_configs_id_organization_id_unique", + "nullsNotDistinct": false, + "columns": [ + "id", + "organization_id" + ] + }, + "esp_configs_id_team_id_unique": { + "name": "esp_configs_id_team_id_unique", + "nullsNotDistinct": false, + "columns": [ + "id", + "team_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "esp_configs_esp_id_check": { + "name": "esp_configs_esp_id_check", + "value": "\"esp_configs\".\"esp_id\" ~ '^esp_'" + }, + "esp_configs_owner_check": { + "name": "esp_configs_owner_check", + "value": "(\"esp_configs\".\"owner_scope\" = 'organization' AND \"esp_configs\".\"organization_id\" IS NOT NULL AND \"esp_configs\".\"team_id\" IS NULL)\n OR (\"esp_configs\".\"owner_scope\" = 'team' AND \"esp_configs\".\"organization_id\" IS NULL AND \"esp_configs\".\"team_id\" IS NOT NULL)" + }, + "esp_configs_status_check": { + "name": "esp_configs_status_check", + "value": "\"esp_configs\".\"status\" IN ('draft', 'active', 'suspended', 'draining', 'retired')" + } + }, + "isRLSEnabled": false + }, + "public.esp_feedback_connections": { + "name": "esp_feedback_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_scope": { + "name": "owner_scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "esp_config_id": { + "name": "esp_config_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_credentials": { + "name": "encrypted_credentials", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "previous_encrypted_credentials": { + "name": "previous_encrypted_credentials", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "previous_credential_expires_at": { + "name": "previous_credential_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expected_topic_arn": { + "name": "expected_topic_arn", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subscription_confirmed_at": { + "name": "subscription_confirmed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "last_received_at": { + "name": "last_received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_verified_at": { + "name": "last_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error_code": { + "name": "last_error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled_at": { + "name": "disabled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "esp_feedback_connections_team_id_idx": { + "name": "esp_feedback_connections_team_id_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "esp_feedback_connections_esp_config_active_idx": { + "name": "esp_feedback_connections_esp_config_active_idx", + "columns": [ + { + "expression": "esp_config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"esp_feedback_connections\".\"esp_config_id\" is not null and \"esp_feedback_connections\".\"status\" not in ('retiring', 'disabled')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "esp_feedback_connections_organization_id_organizations_id_fk": { + "name": "esp_feedback_connections_organization_id_organizations_id_fk", + "tableFrom": "esp_feedback_connections", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "esp_feedback_connections_team_id_teams_id_fk": { + "name": "esp_feedback_connections_team_id_teams_id_fk", + "tableFrom": "esp_feedback_connections", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "esp_feedback_connections_esp_config_id_esp_configs_id_fk": { + "name": "esp_feedback_connections_esp_config_id_esp_configs_id_fk", + "tableFrom": "esp_feedback_connections", + "tableTo": "esp_configs", + "columnsFrom": [ + "esp_config_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "esp_feedback_connections_connection_id_unique": { + "name": "esp_feedback_connections_connection_id_unique", + "nullsNotDistinct": false, + "columns": [ + "connection_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "esp_feedback_connections_connection_id_check": { + "name": "esp_feedback_connections_connection_id_check", + "value": "\"esp_feedback_connections\".\"connection_id\" ~ '^whc_'" + }, + "esp_feedback_connections_owner_check": { + "name": "esp_feedback_connections_owner_check", + "value": "(\n \"esp_feedback_connections\".\"owner_scope\" = 'organization'\n AND \"esp_feedback_connections\".\"organization_id\" IS NOT NULL\n AND \"esp_feedback_connections\".\"team_id\" IS NULL\n ) OR (\n \"esp_feedback_connections\".\"owner_scope\" = 'team'\n AND \"esp_feedback_connections\".\"organization_id\" IS NULL\n AND \"esp_feedback_connections\".\"team_id\" IS NOT NULL\n )" + } + }, + "isRLSEnabled": false + }, + "public.esp_webhook_receipts": { + "name": "esp_webhook_receipts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "receipt_id": { + "name": "receipt_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_request_id": { + "name": "provider_request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_sha256": { + "name": "body_sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_payload": { + "name": "encrypted_payload", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "safe_headers": { + "name": "safe_headers", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_attempts": { + "name": "processing_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error_code": { + "name": "last_error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "received_at": { + "name": "received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "esp_webhook_receipts_status_next_attempt_idx": { + "name": "esp_webhook_receipts_status_next_attempt_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "esp_webhook_receipts_connection_id_provider_request_id_idx": { + "name": "esp_webhook_receipts_connection_id_provider_request_id_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "esp_webhook_receipts_connection_id_esp_feedback_connections_id_fk": { + "name": "esp_webhook_receipts_connection_id_esp_feedback_connections_id_fk", + "tableFrom": "esp_webhook_receipts", + "tableTo": "esp_feedback_connections", + "columnsFrom": [ + "connection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "esp_webhook_receipts_team_id_teams_id_fk": { + "name": "esp_webhook_receipts_team_id_teams_id_fk", + "tableFrom": "esp_webhook_receipts", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "esp_webhook_receipts_receipt_id_unique": { + "name": "esp_webhook_receipts_receipt_id_unique", + "nullsNotDistinct": false, + "columns": [ + "receipt_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "esp_webhook_receipts_receipt_id_check": { + "name": "esp_webhook_receipts_receipt_id_check", + "value": "\"esp_webhook_receipts\".\"receipt_id\" ~ '^whr_'" + } + }, + "isRLSEnabled": false + }, + "public.event_definitions": { + "name": "event_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "properties": { + "name": "properties", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "last_properties": { + "name": "last_properties", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "timestamp with time zone", + "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()" + } + }, + "indexes": { + "event_definitions_team_id_name_idx": { + "name": "event_definitions_team_id_name_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "event_definitions_team_id_teams_id_fk": { + "name": "event_definitions_team_id_teams_id_fk", + "tableFrom": "event_definitions", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jwks": { + "name": "jwks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "alg": { + "name": "alg", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "crv": { + "name": "crv", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mail_dispatch_outbox": { + "name": "mail_dispatch_outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "dispatch_id": { + "name": "dispatch_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outbound_message_id": { + "name": "outbound_message_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "queue_name": { + "name": "queue_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "job_name": { + "name": "job_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "available_at": { + "name": "available_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "publish_attempts": { + "name": "publish_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_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": { + "mail_dispatch_outbox_due_idx": { + "name": "mail_dispatch_outbox_due_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mail_dispatch_outbox_outbound_message_id_outbound_messages_id_fk": { + "name": "mail_dispatch_outbox_outbound_message_id_outbound_messages_id_fk", + "tableFrom": "mail_dispatch_outbox", + "tableTo": "outbound_messages", + "columnsFrom": [ + "outbound_message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mail_dispatch_outbox_dispatch_id_unique": { + "name": "mail_dispatch_outbox_dispatch_id_unique", + "nullsNotDistinct": false, + "columns": [ + "dispatch_id" + ] + }, + "mail_dispatch_outbox_outbound_message_id_unique": { + "name": "mail_dispatch_outbox_outbound_message_id_unique", + "nullsNotDistinct": false, + "columns": [ + "outbound_message_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "mail_dispatch_outbox_dispatch_id_check": { + "name": "mail_dispatch_outbox_dispatch_id_check", + "value": "\"mail_dispatch_outbox\".\"dispatch_id\" ~ '^mdj_'" + }, + "mail_dispatch_outbox_state_check": { + "name": "mail_dispatch_outbox_state_check", + "value": "\"mail_dispatch_outbox\".\"state\" IN ('pending', 'publishing', 'published', 'cancelled')" + } + }, + "isRLSEnabled": false + }, + "public.media": { + "name": "media", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "media_id": { + "name": "media_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "media_lit_id": { + "name": "media_lit_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thumbnail_url": { + "name": "thumbnail_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "alt": { + "name": "alt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "caption": { + "name": "caption", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "media_team_id_media_lit_id_idx": { + "name": "media_team_id_media_lit_id_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "media_lit_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "media_team_id_created_at_idx": { + "name": "media_team_id_created_at_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "media_team_id_teams_id_fk": { + "name": "media_team_id_teams_id_fk", + "tableFrom": "media", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "media_media_id_unique": { + "name": "media_media_id_unique", + "nullsNotDistinct": false, + "columns": [ + "media_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "media_media_id_check": { + "name": "media_media_id_check", + "value": "\"media\".\"media_id\" ~ '^med_'" + } + }, + "isRLSEnabled": false + }, + "public.media_references": { + "name": "media_references", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "media_id": { + "name": "media_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_internal_id": { + "name": "resource_internal_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "resource_public_id": { + "name": "resource_public_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_resource_internal_id": { + "name": "parent_resource_internal_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "parent_resource_public_id": { + "name": "parent_resource_public_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "media_references_resource_idx": { + "name": "media_references_resource_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "media_references_media_id_idx": { + "name": "media_references_media_id_idx", + "columns": [ + { + "expression": "media_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "media_references_resource_media_idx": { + "name": "media_references_resource_media_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "media_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "media_references_team_id_teams_id_fk": { + "name": "media_references_team_id_teams_id_fk", + "tableFrom": "media_references", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "media_references_media_id_media_id_fk": { + "name": "media_references_media_id_media_id_fk", + "tableFrom": "media_references", + "tableTo": "media", + "columnsFrom": [ + "media_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_access_token": { + "name": "oauth_access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorization_code_id": { + "name": "authorization_code_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "requested_user_info_claims": { + "name": "requested_user_info_claims", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "refresh_id": { + "name": "refresh_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked": { + "name": "revoked", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "confirmation": { + "name": "confirmation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "auth_oauth_access_token_client_id_idx": { + "name": "auth_oauth_access_token_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_oauth_access_token_session_id_idx": { + "name": "auth_oauth_access_token_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_oauth_access_token_user_id_idx": { + "name": "auth_oauth_access_token_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_oauth_access_token_authorization_code_id_idx": { + "name": "auth_oauth_access_token_authorization_code_id_idx", + "columns": [ + { + "expression": "authorization_code_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_oauth_access_token_refresh_id_idx": { + "name": "auth_oauth_access_token_refresh_id_idx", + "columns": [ + { + "expression": "refresh_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_access_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_access_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_session_id_session_id_fk": { + "name": "oauth_access_token_session_id_session_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_access_token_user_id_user_id_fk": { + "name": "oauth_access_token_user_id_user_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_refresh_id_oauth_refresh_token_id_fk": { + "name": "oauth_access_token_refresh_id_oauth_refresh_token_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_refresh_token", + "columnsFrom": [ + "refresh_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_token_token_unique": { + "name": "oauth_access_token_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_discovery_id": { + "name": "client_discovery_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "skip_consent": { + "name": "skip_consent", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enable_end_session": { + "name": "enable_end_session", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "client_credentials_scopes": { + "name": "client_credentials_scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contacts": { + "name": "contacts", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "tos": { + "name": "tos", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_id": { + "name": "software_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_version": { + "name": "software_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_statement": { + "name": "software_statement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "post_logout_redirect_uris": { + "name": "post_logout_redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "backchannel_logout_uri": { + "name": "backchannel_logout_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "backchannel_logout_session_required": { + "name": "backchannel_logout_session_required", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "application_type": { + "name": "application_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "jwks": { + "name": "jwks", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "jwks_uri": { + "name": "jwks_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_types": { + "name": "grant_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "response_types": { + "name": "response_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "require_pkce": { + "name": "require_pkce", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "dpop_bound_access_tokens": { + "name": "dpop_bound_access_tokens", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "auth_oauth_client_user_id_idx": { + "name": "auth_oauth_client_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_client_user_id_user_id_fk": { + "name": "oauth_client_user_id_user_id_fk", + "tableFrom": "oauth_client", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_client_client_id_unique": { + "name": "oauth_client_client_id_unique", + "nullsNotDistinct": false, + "columns": [ + "client_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client_assertion": { + "name": "oauth_client_assertion", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client_resource": { + "name": "oauth_client_resource", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "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": false + } + }, + "indexes": { + "auth_oauth_client_resource_client_id_idx": { + "name": "auth_oauth_client_resource_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_oauth_client_resource_resource_id_idx": { + "name": "auth_oauth_client_resource_resource_id_idx", + "columns": [ + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_oauth_client_resource_client_id_resource_id_idx": { + "name": "auth_oauth_client_resource_client_id_resource_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_client_resource_client_id_oauth_client_client_id_fk": { + "name": "oauth_client_resource_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_client_resource", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_client_resource_resource_id_oauth_resource_identifier_fk": { + "name": "oauth_client_resource_resource_id_oauth_resource_identifier_fk", + "tableFrom": "oauth_client_resource", + "tableTo": "oauth_resource", + "columnsFrom": [ + "resource_id" + ], + "columnsTo": [ + "identifier" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_consent": { + "name": "oauth_consent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "requested_user_info_claims": { + "name": "requested_user_info_claims", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "auth_oauth_consent_client_id_idx": { + "name": "auth_oauth_consent_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_oauth_consent_user_id_idx": { + "name": "auth_oauth_consent_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_consent_client_id_oauth_client_client_id_fk": { + "name": "oauth_consent_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consent_user_id_user_id_fk": { + "name": "oauth_consent_user_id_user_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_post_login_team_selections": { + "name": "oauth_post_login_team_selections", + "schema": "", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_post_login_team_selections_session_id_session_id_fk": { + "name": "oauth_post_login_team_selections_session_id_session_id_fk", + "tableFrom": "oauth_post_login_team_selections", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_post_login_team_selections_team_id_teams_id_fk": { + "name": "oauth_post_login_team_selections_team_id_teams_id_fk", + "tableFrom": "oauth_post_login_team_selections", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_refresh_token": { + "name": "oauth_refresh_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorization_code_id": { + "name": "authorization_code_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "requested_user_info_claims": { + "name": "requested_user_info_claims", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked": { + "name": "revoked", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "rotated_at": { + "name": "rotated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "rotation_replay_response": { + "name": "rotation_replay_response", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rotation_replay_expires_at": { + "name": "rotation_replay_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "auth_time": { + "name": "auth_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "confirmation": { + "name": "confirmation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "auth_oauth_refresh_token_client_id_idx": { + "name": "auth_oauth_refresh_token_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_oauth_refresh_token_authorization_code_id_idx": { + "name": "auth_oauth_refresh_token_authorization_code_id_idx", + "columns": [ + { + "expression": "authorization_code_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_oauth_refresh_token_session_id_idx": { + "name": "auth_oauth_refresh_token_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_oauth_refresh_token_user_id_idx": { + "name": "auth_oauth_refresh_token_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_refresh_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_refresh_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_token_session_id_session_id_fk": { + "name": "oauth_refresh_token_session_id_session_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_refresh_token_user_id_user_id_fk": { + "name": "oauth_refresh_token_user_id_user_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_refresh_token_token_unique": { + "name": "oauth_refresh_token_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_resource": { + "name": "oauth_resource", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token_ttl": { + "name": "access_token_ttl", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "refresh_token_ttl": { + "name": "refresh_token_ttl", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "signing_algorithm": { + "name": "signing_algorithm", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "signing_key_id": { + "name": "signing_key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_scopes": { + "name": "allowed_scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "custom_claims": { + "name": "custom_claims", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "dpop_bound_access_tokens_required": { + "name": "dpop_bound_access_tokens_required", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "policy_version": { + "name": "policy_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_resource_identifier_unique": { + "name": "oauth_resource_identifier_unique", + "nullsNotDistinct": false, + "columns": [ + "identifier" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ongoing_sequences": { + "name": "ongoing_sequences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sequence_id": { + "name": "sequence_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "next_email_scheduled_time": { + "name": "next_email_scheduled_time", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "retry_count": { + "name": "retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "priority": { + "name": "priority", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sent_email_ids": { + "name": "sent_email_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "trigger_event": { + "name": "trigger_event", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "ongoing_sequences_sequence_id_contact_id_idx": { + "name": "ongoing_sequences_sequence_id_contact_id_idx", + "columns": [ + { + "expression": "sequence_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "contact_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ongoing_sequences_next_email_scheduled_time_idx": { + "name": "ongoing_sequences_next_email_scheduled_time_idx", + "columns": [ + { + "expression": "next_email_scheduled_time", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ongoing_sequences_team_id_teams_id_fk": { + "name": "ongoing_sequences_team_id_teams_id_fk", + "tableFrom": "ongoing_sequences", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ongoing_sequences_sequence_id_sequences_id_fk": { + "name": "ongoing_sequences_sequence_id_sequences_id_fk", + "tableFrom": "ongoing_sequences", + "tableTo": "sequences", + "columnsFrom": [ + "sequence_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ongoing_sequences_contact_id_contacts_id_fk": { + "name": "ongoing_sequences_contact_id_contacts_id_fk", + "tableFrom": "ongoing_sequences", + "tableTo": "contacts", + "columnsFrom": [ + "contact_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_api_keys": { + "name": "organization_api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "organization_api_key_id": { + "name": "organization_api_key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organization_api_keys_organization_id_idx": { + "name": "organization_api_keys_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_api_keys_organization_id_organizations_id_fk": { + "name": "organization_api_keys_organization_id_organizations_id_fk", + "tableFrom": "organization_api_keys", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_api_keys_created_by_user_id_user_id_fk": { + "name": "organization_api_keys_created_by_user_id_user_id_fk", + "tableFrom": "organization_api_keys", + "tableTo": "user", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_api_keys_organization_api_key_id_unique": { + "name": "organization_api_keys_organization_api_key_id_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_api_key_id" + ] + }, + "organization_api_keys_key_hash_unique": { + "name": "organization_api_keys_key_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "key_hash" + ] + } + }, + "policies": {}, + "checkConstraints": { + "organization_api_keys_public_id_check": { + "name": "organization_api_keys_public_id_check", + "value": "\"organization_api_keys\".\"organization_api_key_id\" ~ '^oak_'" + } + }, + "isRLSEnabled": false + }, + "public.organization_audit_events": { + "name": "organization_audit_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "actor_type": { + "name": "actor_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "esp_config_id": { + "name": "esp_config_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "esp_grant_id": { + "name": "esp_grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organization_audit_events_organization_id_created_at_idx": { + "name": "organization_audit_events_organization_id_created_at_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "organization_audit_events_team_id_created_at_idx": { + "name": "organization_audit_events_team_id_created_at_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_audit_events_organization_id_organizations_id_fk": { + "name": "organization_audit_events_organization_id_organizations_id_fk", + "tableFrom": "organization_audit_events", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_delivery_policies": { + "name": "organization_delivery_policies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "default_esp_config_id": { + "name": "default_esp_config_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "auto_grant_default_esp": { + "name": "auto_grant_default_esp", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "default_daily_limit": { + "name": "default_daily_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "default_monthly_limit": { + "name": "default_monthly_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "aggregate_daily_limit": { + "name": "aggregate_daily_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "aggregate_monthly_limit": { + "name": "aggregate_monthly_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "team_esp_enabled_by_default": { + "name": "team_esp_enabled_by_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "team_can_change_default": { + "name": "team_can_change_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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": {}, + "foreignKeys": { + "organization_delivery_policies_organization_id_organizations_id_fk": { + "name": "organization_delivery_policies_organization_id_organizations_id_fk", + "tableFrom": "organization_delivery_policies", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_delivery_policies_default_esp_fk": { + "name": "organization_delivery_policies_default_esp_fk", + "tableFrom": "organization_delivery_policies", + "tableTo": "esp_configs", + "columnsFrom": [ + "default_esp_config_id", + "organization_id" + ], + "columnsTo": [ + "id", + "organization_id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_delivery_policies_organization_id_unique": { + "name": "organization_delivery_policies_organization_id_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "organization_delivery_policies_limit_check": { + "name": "organization_delivery_policies_limit_check", + "value": "(\"organization_delivery_policies\".\"default_daily_limit\" IS NULL OR \"organization_delivery_policies\".\"default_daily_limit\" >= 0)\n AND (\"organization_delivery_policies\".\"default_monthly_limit\" IS NULL OR \"organization_delivery_policies\".\"default_monthly_limit\" >= 0)\n AND (\"organization_delivery_policies\".\"aggregate_daily_limit\" IS NULL OR \"organization_delivery_policies\".\"aggregate_daily_limit\" >= 0)\n AND (\"organization_delivery_policies\".\"aggregate_monthly_limit\" IS NULL OR \"organization_delivery_policies\".\"aggregate_monthly_limit\" >= 0)" + } + }, + "isRLSEnabled": false + }, + "public.organization_esp_quota_reservations": { + "name": "organization_esp_quota_reservations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "reservation_id": { + "name": "reservation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outbound_message_id": { + "name": "outbound_message_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "day_period_start": { + "name": "day_period_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "month_period_start": { + "name": "month_period_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'reserved'" + }, + "release_reason": { + "name": "release_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "committed_at": { + "name": "committed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "released_at": { + "name": "released_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "organization_esp_quota_reservations_outbound_message_id_outbound_messages_id_fk": { + "name": "organization_esp_quota_reservations_outbound_message_id_outbound_messages_id_fk", + "tableFrom": "organization_esp_quota_reservations", + "tableTo": "outbound_messages", + "columnsFrom": [ + "outbound_message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "organization_esp_quota_reservations_grant_id_esp_config_team_grants_id_fk": { + "name": "organization_esp_quota_reservations_grant_id_esp_config_team_grants_id_fk", + "tableFrom": "organization_esp_quota_reservations", + "tableTo": "esp_config_team_grants", + "columnsFrom": [ + "grant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "organization_esp_quota_reservations_organization_id_organizations_id_fk": { + "name": "organization_esp_quota_reservations_organization_id_organizations_id_fk", + "tableFrom": "organization_esp_quota_reservations", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "organization_esp_quota_reservations_grant_organization_fk": { + "name": "organization_esp_quota_reservations_grant_organization_fk", + "tableFrom": "organization_esp_quota_reservations", + "tableTo": "esp_config_team_grants", + "columnsFrom": [ + "grant_id", + "organization_id" + ], + "columnsTo": [ + "id", + "organization_id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_esp_quota_reservations_reservation_id_unique": { + "name": "organization_esp_quota_reservations_reservation_id_unique", + "nullsNotDistinct": false, + "columns": [ + "reservation_id" + ] + }, + "organization_esp_quota_reservations_outbound_message_id_unique": { + "name": "organization_esp_quota_reservations_outbound_message_id_unique", + "nullsNotDistinct": false, + "columns": [ + "outbound_message_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "organization_esp_quota_reservations_reservation_id_check": { + "name": "organization_esp_quota_reservations_reservation_id_check", + "value": "\"organization_esp_quota_reservations\".\"reservation_id\" ~ '^qrs_'" + }, + "organization_esp_quota_reservations_state_check": { + "name": "organization_esp_quota_reservations_state_check", + "value": "\"organization_esp_quota_reservations\".\"state\" IN ('reserved', 'committed', 'released')" + } + }, + "isRLSEnabled": false + }, + "public.organization_esp_usage_buckets": { + "name": "organization_esp_usage_buckets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "bucket_scope": { + "name": "bucket_scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "period_type": { + "name": "period_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period_start": { + "name": "period_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "reserved_count": { + "name": "reserved_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "accepted_count": { + "name": "accepted_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organization_esp_usage_buckets_grant_period_idx": { + "name": "organization_esp_usage_buckets_grant_period_idx", + "columns": [ + { + "expression": "grant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"organization_esp_usage_buckets\".\"grant_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "organization_esp_usage_buckets_organization_period_idx": { + "name": "organization_esp_usage_buckets_organization_period_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"organization_esp_usage_buckets\".\"bucket_scope\" = 'organization'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_esp_usage_buckets_organization_id_organizations_id_fk": { + "name": "organization_esp_usage_buckets_organization_id_organizations_id_fk", + "tableFrom": "organization_esp_usage_buckets", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "organization_esp_usage_buckets_grant_id_esp_config_team_grants_id_fk": { + "name": "organization_esp_usage_buckets_grant_id_esp_config_team_grants_id_fk", + "tableFrom": "organization_esp_usage_buckets", + "tableTo": "esp_config_team_grants", + "columnsFrom": [ + "grant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "organization_esp_usage_buckets_grant_organization_fk": { + "name": "organization_esp_usage_buckets_grant_organization_fk", + "tableFrom": "organization_esp_usage_buckets", + "tableTo": "esp_config_team_grants", + "columnsFrom": [ + "grant_id", + "organization_id" + ], + "columnsTo": [ + "id", + "organization_id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "organization_esp_usage_buckets_scope_check": { + "name": "organization_esp_usage_buckets_scope_check", + "value": "(\n \"organization_esp_usage_buckets\".\"bucket_scope\" = 'grant' AND \"organization_esp_usage_buckets\".\"grant_id\" IS NOT NULL\n ) OR (\n \"organization_esp_usage_buckets\".\"bucket_scope\" = 'organization' AND \"organization_esp_usage_buckets\".\"grant_id\" IS NULL\n )" + }, + "organization_esp_usage_buckets_period_check": { + "name": "organization_esp_usage_buckets_period_check", + "value": "\"organization_esp_usage_buckets\".\"period_type\" IN ('day', 'month')" + }, + "organization_esp_usage_buckets_count_check": { + "name": "organization_esp_usage_buckets_count_check", + "value": "\"organization_esp_usage_buckets\".\"reserved_count\" >= 0 AND \"organization_esp_usage_buckets\".\"accepted_count\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.organization_members": { + "name": "organization_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "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": { + "organization_members_organization_id_user_id_idx": { + "name": "organization_members_organization_id_user_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_members_organization_id_organizations_id_fk": { + "name": "organization_members_organization_id_organizations_id_fk", + "tableFrom": "organization_members", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_members_user_id_user_id_fk": { + "name": "organization_members_user_id_user_id_fk", + "tableFrom": "organization_members", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "organization_members_role_check": { + "name": "organization_members_role_check", + "value": "\"organization_members\".\"role\" IN ('owner', 'admin', 'member')" + } + }, + "isRLSEnabled": false + }, + "public.organization_plan_policies": { + "name": "organization_plan_policies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "teams_limit_override": { + "name": "teams_limit_override", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "contacts_limit_override": { + "name": "contacts_limit_override", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "marketing_ramp_stage": { + "name": "marketing_ramp_stage", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "marketing_ramp_clean_days": { + "name": "marketing_ramp_clean_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "marketing_ramp_evaluated_at": { + "name": "marketing_ramp_evaluated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "past_due_since": { + "name": "past_due_since", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "pacing_exempt": { + "name": "pacing_exempt", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "signup_risk_score": { + "name": "signup_risk_score", + "type": "integer", + "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": {}, + "foreignKeys": { + "organization_plan_policies_organization_id_organizations_id_fk": { + "name": "organization_plan_policies_organization_id_organizations_id_fk", + "tableFrom": "organization_plan_policies", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_plan_policies_organization_id_unique": { + "name": "organization_plan_policies_organization_id_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "organization_plan_policies_overrides_check": { + "name": "organization_plan_policies_overrides_check", + "value": "(\"organization_plan_policies\".\"teams_limit_override\" IS NULL OR \"organization_plan_policies\".\"teams_limit_override\" > 0)\n AND (\"organization_plan_policies\".\"contacts_limit_override\" IS NULL OR \"organization_plan_policies\".\"contacts_limit_override\" > 0)" + }, + "organization_plan_policies_ramp_check": { + "name": "organization_plan_policies_ramp_check", + "value": "\"organization_plan_policies\".\"marketing_ramp_stage\" BETWEEN 0 AND 3 AND \"organization_plan_policies\".\"marketing_ramp_clean_days\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "pending_team_name": { + "name": "pending_team_name", + "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": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organizations_organization_id_unique": { + "name": "organizations_organization_id_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "organizations_organization_id_check": { + "name": "organizations_organization_id_check", + "value": "\"organizations\".\"organization_id\" ~ '^org_'" + }, + "organizations_status_check": { + "name": "organizations_status_check", + "value": "\"organizations\".\"status\" IN ('pending_payment', 'active', 'suspended', 'abandoned', 'closed')" + } + }, + "isRLSEnabled": false + }, + "public.outbound_messages": { + "name": "outbound_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "delivery_source_type": { + "name": "delivery_source_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "esp_config_id": { + "name": "esp_config_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "esp_grant_id": { + "name": "esp_grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "feedback_connection_id": { + "name": "feedback_connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "submission_key": { + "name": "submission_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "campaign_delivery_id": { + "name": "campaign_delivery_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "transactional_email_id": { + "name": "transactional_email_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "recipient_email": { + "name": "recipient_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_recipient": { + "name": "normalized_recipient", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rfc_message_id": { + "name": "rfc_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_message_id": { + "name": "provider_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "delivery_status": { + "name": "delivery_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "feedback_status": { + "name": "feedback_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "bounced_at": { + "name": "bounced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "complained_at": { + "name": "complained_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_event_at": { + "name": "last_event_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "outbound_messages_team_id_created_at_idx": { + "name": "outbound_messages_team_id_created_at_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbound_messages_connection_provider_msg_idx": { + "name": "outbound_messages_connection_provider_msg_idx", + "columns": [ + { + "expression": "feedback_connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbound_messages_team_id_recipient_created_at_idx": { + "name": "outbound_messages_team_id_recipient_created_at_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "normalized_recipient", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "outbound_messages_team_id_teams_id_fk": { + "name": "outbound_messages_team_id_teams_id_fk", + "tableFrom": "outbound_messages", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "outbound_messages_esp_config_id_esp_configs_id_fk": { + "name": "outbound_messages_esp_config_id_esp_configs_id_fk", + "tableFrom": "outbound_messages", + "tableTo": "esp_configs", + "columnsFrom": [ + "esp_config_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "outbound_messages_esp_grant_id_esp_config_team_grants_id_fk": { + "name": "outbound_messages_esp_grant_id_esp_config_team_grants_id_fk", + "tableFrom": "outbound_messages", + "tableTo": "esp_config_team_grants", + "columnsFrom": [ + "esp_grant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "outbound_messages_feedback_connection_id_esp_feedback_connections_id_fk": { + "name": "outbound_messages_feedback_connection_id_esp_feedback_connections_id_fk", + "tableFrom": "outbound_messages", + "tableTo": "esp_feedback_connections", + "columnsFrom": [ + "feedback_connection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "outbound_messages_campaign_delivery_id_email_deliveries_id_fk": { + "name": "outbound_messages_campaign_delivery_id_email_deliveries_id_fk", + "tableFrom": "outbound_messages", + "tableTo": "email_deliveries", + "columnsFrom": [ + "campaign_delivery_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "outbound_messages_transactional_email_id_transactional_emails_id_fk": { + "name": "outbound_messages_transactional_email_id_transactional_emails_id_fk", + "tableFrom": "outbound_messages", + "tableTo": "transactional_emails", + "columnsFrom": [ + "transactional_email_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "outbound_messages_message_id_unique": { + "name": "outbound_messages_message_id_unique", + "nullsNotDistinct": false, + "columns": [ + "message_id" + ] + }, + "outbound_messages_submission_key_unique": { + "name": "outbound_messages_submission_key_unique", + "nullsNotDistinct": false, + "columns": [ + "submission_key" + ] + } + }, + "policies": {}, + "checkConstraints": { + "outbound_messages_message_id_check": { + "name": "outbound_messages_message_id_check", + "value": "\"outbound_messages\".\"message_id\" ~ '^msg_'" + }, + "outbound_messages_delivery_pin_check": { + "name": "outbound_messages_delivery_pin_check", + "value": "(\n \"outbound_messages\".\"delivery_source_type\" = 'team'\n AND \"outbound_messages\".\"esp_config_id\" IS NOT NULL\n AND \"outbound_messages\".\"esp_grant_id\" IS NULL\n ) OR (\n \"outbound_messages\".\"delivery_source_type\" = 'organization'\n AND \"outbound_messages\".\"esp_config_id\" IS NOT NULL\n AND \"outbound_messages\".\"esp_grant_id\" IS NOT NULL\n ) OR (\n \"outbound_messages\".\"delivery_source_type\" IN ('team', 'organization')\n AND \"outbound_messages\".\"esp_config_id\" IS NULL\n AND \"outbound_messages\".\"esp_grant_id\" IS NULL\n AND \"outbound_messages\".\"delivery_status\" <> 'queued'\n )" + } + }, + "isRLSEnabled": false + }, + "public.plan_send_reservations": { + "name": "plan_send_reservations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "outbound_message_id": { + "name": "outbound_message_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "bucket_id": { + "name": "bucket_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'reserved'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "committed_at": { + "name": "committed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "released_at": { + "name": "released_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": { + "plan_send_reservations_outbound_uidx": { + "name": "plan_send_reservations_outbound_uidx", + "columns": [ + { + "expression": "outbound_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plan_send_reservations_expiry_idx": { + "name": "plan_send_reservations_expiry_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plan_send_reservations_organization_id_organizations_id_fk": { + "name": "plan_send_reservations_organization_id_organizations_id_fk", + "tableFrom": "plan_send_reservations", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "plan_send_reservations_bucket_id_plan_send_usage_buckets_id_fk": { + "name": "plan_send_reservations_bucket_id_plan_send_usage_buckets_id_fk", + "tableFrom": "plan_send_reservations", + "tableTo": "plan_send_usage_buckets", + "columnsFrom": [ + "bucket_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "plan_send_reservations_amount_check": { + "name": "plan_send_reservations_amount_check", + "value": "\"plan_send_reservations\".\"amount\" > 0" + }, + "plan_send_reservations_state_check": { + "name": "plan_send_reservations_state_check", + "value": "\"plan_send_reservations\".\"state\" IN ('reserved', 'committed', 'released')" + } + }, + "isRLSEnabled": false + }, + "public.plan_send_usage_buckets": { + "name": "plan_send_usage_buckets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "bucket_month": { + "name": "bucket_month", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "committed": { + "name": "committed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reserved": { + "name": "reserved", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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": { + "plan_send_usage_buckets_organization_month_uidx": { + "name": "plan_send_usage_buckets_organization_month_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "bucket_month", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plan_send_usage_buckets_organization_id_organizations_id_fk": { + "name": "plan_send_usage_buckets_organization_id_organizations_id_fk", + "tableFrom": "plan_send_usage_buckets", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "plan_send_usage_buckets_count_check": { + "name": "plan_send_usage_buckets_count_check", + "value": "\"plan_send_usage_buckets\".\"committed\" >= 0 AND \"plan_send_usage_buckets\".\"reserved\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.reputation_connections": { + "name": "reputation_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_secret": { + "name": "encrypted_secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_label": { + "name": "account_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "alert_state": { + "name": "alert_state", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "subject_state": { + "name": "subject_state", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "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": { + "reputation_connections_team_id_provider_idx": { + "name": "reputation_connections_team_id_provider_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reputation_connections_next_sync_at_idx": { + "name": "reputation_connections_next_sync_at_idx", + "columns": [ + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reputation_connections_team_id_teams_id_fk": { + "name": "reputation_connections_team_id_teams_id_fk", + "tableFrom": "reputation_connections", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "reputation_connections_provider_check": { + "name": "reputation_connections_provider_check", + "value": "\"reputation_connections\".\"provider\" IN ('google_postmaster', 'microsoft_snds')" + }, + "reputation_connections_status_check": { + "name": "reputation_connections_status_check", + "value": "\"reputation_connections\".\"status\" IN ('active', 'error')" + } + }, + "isRLSEnabled": false + }, + "public.reputation_daily_stats": { + "name": "reputation_daily_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "day": { + "name": "day", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "metrics": { + "name": "metrics", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "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": { + "reputation_daily_stats_connection_subject_day_idx": { + "name": "reputation_daily_stats_connection_subject_day_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "day", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reputation_daily_stats_connection_id_reputation_connections_id_fk": { + "name": "reputation_daily_stats_connection_id_reputation_connections_id_fk", + "tableFrom": "reputation_daily_stats", + "tableTo": "reputation_connections", + "columnsFrom": [ + "connection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rules": { + "name": "rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "rule_id": { + "name": "rule_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sequence_id": { + "name": "sequence_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "event_date_in_millis": { + "name": "event_date_in_millis", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "event_data": { + "name": "event_data", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event_filter": { + "name": "event_filter", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "rules_team_id_event_event_data_idx": { + "name": "rules_team_id_event_event_data_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_data", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rules_team_id_teams_id_fk": { + "name": "rules_team_id_teams_id_fk", + "tableFrom": "rules", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "rules_sequence_id_sequences_id_fk": { + "name": "rules_sequence_id_sequences_id_fk", + "tableFrom": "rules", + "tableTo": "sequences", + "columnsFrom": [ + "sequence_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "rules_rule_id_unique": { + "name": "rules_rule_id_unique", + "nullsNotDistinct": false, + "columns": [ + "rule_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "rules_rule_id_check": { + "name": "rules_rule_id_check", + "value": "\"rules\".\"rule_id\" ~ '^rule_'" + } + }, + "isRLSEnabled": false + }, + "public.segments": { + "name": "segments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "segment_id": { + "name": "segment_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filter": { + "name": "filter", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "segments_team_id_name_idx": { + "name": "segments_team_id_name_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "segments_team_id_teams_id_fk": { + "name": "segments_team_id_teams_id_fk", + "tableFrom": "segments", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "segments_segment_id_unique": { + "name": "segments_segment_id_unique", + "nullsNotDistinct": false, + "columns": [ + "segment_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "segments_segment_id_check": { + "name": "segments_segment_id_check", + "value": "\"segments\".\"segment_id\" ~ '^seg_'" + } + }, + "isRLSEnabled": false + }, + "public.sending_domains": { + "name": "sending_domains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "domain_id": { + "name": "domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "challenge_token_hash": { + "name": "challenge_token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_check_at": { + "name": "next_check_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "failed_check_count": { + "name": "failed_check_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "first_failed_at": { + "name": "first_failed_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": { + "sending_domains_organization_domain_uidx": { + "name": "sending_domains_organization_domain_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sending_domains_organization_id_organizations_id_fk": { + "name": "sending_domains_organization_id_organizations_id_fk", + "tableFrom": "sending_domains", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sending_domains_domain_id_unique": { + "name": "sending_domains_domain_id_unique", + "nullsNotDistinct": false, + "columns": [ + "domain_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "sending_domains_domain_id_check": { + "name": "sending_domains_domain_id_check", + "value": "\"sending_domains\".\"domain_id\" ~ '^domain_'" + }, + "sending_domains_status_check": { + "name": "sending_domains_status_check", + "value": "\"sending_domains\".\"status\" IN ('pending', 'verified', 'revoked', 'failed')" + }, + "sending_domains_failed_check_count_check": { + "name": "sending_domains_failed_check_count_check", + "value": "\"sending_domains\".\"failed_check_count\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.sequence_emails": { + "name": "sequence_emails", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "sequence_id": { + "name": "sequence_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email_id": { + "name": "email_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "delay_in_millis": { + "name": "delay_in_millis", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 86400000 + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "template_id": { + "name": "template_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action_type": { + "name": "action_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action_data": { + "name": "action_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_test_sent_at": { + "name": "last_test_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "content_scan_verdict": { + "name": "content_scan_verdict", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_scan_hash": { + "name": "content_scan_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_scan_reasons": { + "name": "content_scan_reasons", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "content_scanned_at": { + "name": "content_scanned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "sequence_emails_sequence_id_email_id_idx": { + "name": "sequence_emails_sequence_id_email_id_idx", + "columns": [ + { + "expression": "sequence_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sequence_emails_sequence_id_sequences_id_fk": { + "name": "sequence_emails_sequence_id_sequences_id_fk", + "tableFrom": "sequence_emails", + "tableTo": "sequences", + "columnsFrom": [ + "sequence_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "sequence_emails_email_id_check": { + "name": "sequence_emails_email_id_check", + "value": "\"sequence_emails\".\"email_id\" ~ '^email_'" + } + }, + "isRLSEnabled": false + }, + "public.sequence_entries": { + "name": "sequence_entries", + "schema": "", + "columns": { + "sequence_id": { + "name": "sequence_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "first_entered_at": { + "name": "first_entered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_entered_at": { + "name": "last_entered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "entry_count": { + "name": "entry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + } + }, + "indexes": {}, + "foreignKeys": { + "sequence_entries_sequence_id_sequences_id_fk": { + "name": "sequence_entries_sequence_id_sequences_id_fk", + "tableFrom": "sequence_entries", + "tableTo": "sequences", + "columnsFrom": [ + "sequence_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sequence_entries_contact_id_contacts_id_fk": { + "name": "sequence_entries_contact_id_contacts_id_fk", + "tableFrom": "sequence_entries", + "tableTo": "contacts", + "columnsFrom": [ + "contact_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sequence_entries_sequence_id_contact_id_pk": { + "name": "sequence_entries_sequence_id_contact_id_pk", + "columns": [ + "sequence_id", + "contact_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sequences": { + "name": "sequences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sequence_id": { + "name": "sequence_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "delivery_source_intent": { + "name": "delivery_source_intent", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "delivery_source_type": { + "name": "delivery_source_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outbox_id": { + "name": "outbox_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "esp_grant_id": { + "name": "esp_grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trigger_data": { + "name": "trigger_data", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trigger_filter": { + "name": "trigger_filter", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "reentry": { + "name": "reentry", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'every_time'" + }, + "reentry_period_days": { + "name": "reentry_period_days", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "filter": { + "name": "filter", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "exclude_filter": { + "name": "exclude_filter", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "emails_order": { + "name": "emails_order", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "entrants": { + "name": "entrants", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "report": { + "name": "report", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "view_in_browser": { + "name": "view_in_browser", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "web_published": { + "name": "web_published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "web_slug": { + "name": "web_slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "web_seo_title": { + "name": "web_seo_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "web_seo_description": { + "name": "web_seo_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "web_og_image": { + "name": "web_og_image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "web_noindex": { + "name": "web_noindex", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "web_canonical_url": { + "name": "web_canonical_url", + "type": "text", + "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": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "sequences_team_id_web_slug_idx": { + "name": "sequences_team_id_web_slug_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "web_slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"sequences\".\"web_slug\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sequences_team_id_teams_id_fk": { + "name": "sequences_team_id_teams_id_fk", + "tableFrom": "sequences", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sequences_outbox_id_esp_configs_id_fk": { + "name": "sequences_outbox_id_esp_configs_id_fk", + "tableFrom": "sequences", + "tableTo": "esp_configs", + "columnsFrom": [ + "outbox_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "sequences_esp_grant_id_esp_config_team_grants_id_fk": { + "name": "sequences_esp_grant_id_esp_config_team_grants_id_fk", + "tableFrom": "sequences", + "tableTo": "esp_config_team_grants", + "columnsFrom": [ + "esp_grant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sequences_sequence_id_unique": { + "name": "sequences_sequence_id_unique", + "nullsNotDistinct": false, + "columns": [ + "sequence_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "sequences_sequence_id_check": { + "name": "sequences_sequence_id_check", + "value": "\"sequences\".\"sequence_id\" ~ '^seq_'" + }, + "sequences_delivery_pin_check": { + "name": "sequences_delivery_pin_check", + "value": "(\n \"sequences\".\"delivery_source_type\" IS NULL\n AND \"sequences\".\"outbox_id\" IS NULL\n AND \"sequences\".\"esp_grant_id\" IS NULL\n ) OR (\n \"sequences\".\"delivery_source_type\" = 'team'\n AND \"sequences\".\"outbox_id\" IS NOT NULL\n AND \"sequences\".\"esp_grant_id\" IS NULL\n ) OR (\n \"sequences\".\"delivery_source_type\" = 'organization'\n AND \"sequences\".\"outbox_id\" IS NOT NULL\n AND \"sequences\".\"esp_grant_id\" IS NOT NULL\n )" + } + }, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "auth_session_user_id_idx": { + "name": "auth_session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "mailing_address": { + "name": "mailing_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "web_enabled": { + "name": "web_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "web_handle": { + "name": "web_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "web_title": { + "name": "web_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "web_description": { + "name": "web_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "web_signup_form_id": { + "name": "web_signup_form_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tracking_domain": { + "name": "tracking_domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tracking_domain_status": { + "name": "tracking_domain_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tracking_domain_error": { + "name": "tracking_domain_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tracking_domain_verified_at": { + "name": "tracking_domain_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "tracking_domain_checked_at": { + "name": "tracking_domain_checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "tracking_domain_failures": { + "name": "tracking_domain_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "retired_tracking_domains": { + "name": "retired_tracking_domains", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_team_id_teams_id_fk": { + "name": "settings_team_id_teams_id_fk", + "tableFrom": "settings", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "settings_web_signup_form_id_signup_forms_id_fk": { + "name": "settings_web_signup_form_id_signup_forms_id_fk", + "tableFrom": "settings", + "tableTo": "signup_forms", + "columnsFrom": [ + "web_signup_form_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_team_id_unique": { + "name": "settings_team_id_unique", + "nullsNotDistinct": false, + "columns": [ + "team_id" + ] + }, + "settings_web_handle_unique": { + "name": "settings_web_handle_unique", + "nullsNotDistinct": false, + "columns": [ + "web_handle" + ] + }, + "settings_tracking_domain_unique": { + "name": "settings_tracking_domain_unique", + "nullsNotDistinct": false, + "columns": [ + "tracking_domain" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.signup_confirmations": { + "name": "signup_confirmations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "form_id": { + "name": "form_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "confirmed_at": { + "name": "confirmed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "signup_confirmations_form_id_email_idx": { + "name": "signup_confirmations_form_id_email_idx", + "columns": [ + { + "expression": "form_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "signup_confirmations_form_id_signup_forms_id_fk": { + "name": "signup_confirmations_form_id_signup_forms_id_fk", + "tableFrom": "signup_confirmations", + "tableTo": "signup_forms", + "columnsFrom": [ + "form_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "signup_confirmations_token_unique": { + "name": "signup_confirmations_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.signup_forms": { + "name": "signup_forms", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "form_id": { + "name": "form_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "double_opt_in": { + "name": "double_opt_in", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "redirect_url": { + "name": "redirect_url", + "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": { + "signup_forms_team_id_idx": { + "name": "signup_forms_team_id_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "signup_forms_team_id_teams_id_fk": { + "name": "signup_forms_team_id_teams_id_fk", + "tableFrom": "signup_forms", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "signup_forms_form_id_unique": { + "name": "signup_forms_form_id_unique", + "nullsNotDistinct": false, + "columns": [ + "form_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "signup_forms_form_id_check": { + "name": "signup_forms_form_id_check", + "value": "\"signup_forms\".\"form_id\" ~ '^frm_'" + } + }, + "isRLSEnabled": false + }, + "public.team_api_keys": { + "name": "team_api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "team_api_key_id": { + "name": "team_api_key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by_type": { + "name": "created_by_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "created_by_id": { + "name": "created_by_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "team_api_keys_team_id_idx": { + "name": "team_api_keys_team_id_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_api_keys_team_id_teams_id_fk": { + "name": "team_api_keys_team_id_teams_id_fk", + "tableFrom": "team_api_keys", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "team_api_keys_team_api_key_id_unique": { + "name": "team_api_keys_team_api_key_id_unique", + "nullsNotDistinct": false, + "columns": [ + "team_api_key_id" + ] + }, + "team_api_keys_key_hash_unique": { + "name": "team_api_keys_key_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "key_hash" + ] + } + }, + "policies": {}, + "checkConstraints": { + "team_api_keys_public_id_check": { + "name": "team_api_keys_public_id_check", + "value": "\"team_api_keys\".\"team_api_key_id\" ~ '^tak_'" + }, + "team_api_keys_created_by_type_check": { + "name": "team_api_keys_created_by_type_check", + "value": "\"team_api_keys\".\"created_by_type\" IN ('user', 'organization_key', 'system')" + } + }, + "isRLSEnabled": false + }, + "public.team_delivery_settings": { + "name": "team_delivery_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "team_esp_enabled": { + "name": "team_esp_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "team_can_change_default": { + "name": "team_can_change_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "default_source": { + "name": "default_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_team_esp_config_id": { + "name": "default_team_esp_config_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": {}, + "foreignKeys": { + "team_delivery_settings_team_id_teams_id_fk": { + "name": "team_delivery_settings_team_id_teams_id_fk", + "tableFrom": "team_delivery_settings", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_delivery_settings_default_team_esp_fk": { + "name": "team_delivery_settings_default_team_esp_fk", + "tableFrom": "team_delivery_settings", + "tableTo": "esp_configs", + "columnsFrom": [ + "default_team_esp_config_id", + "team_id" + ], + "columnsTo": [ + "id", + "team_id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "team_delivery_settings_team_id_unique": { + "name": "team_delivery_settings_team_id_unique", + "nullsNotDistinct": false, + "columns": [ + "team_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "team_delivery_settings_default_source_check": { + "name": "team_delivery_settings_default_source_check", + "value": "\"team_delivery_settings\".\"default_source\" IS NULL OR \"team_delivery_settings\".\"default_source\" IN ('organization', 'team')" + } + }, + "isRLSEnabled": false + }, + "public.team_members": { + "name": "team_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "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": { + "team_members_team_id_user_id_idx": { + "name": "team_members_team_id_user_id_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_members_team_id_teams_id_fk": { + "name": "team_members_team_id_teams_id_fk", + "tableFrom": "team_members", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_members_user_id_user_id_fk": { + "name": "team_members_user_id_user_id_fk", + "tableFrom": "team_members", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "team_members_role_check": { + "name": "team_members_role_check", + "value": "\"team_members\".\"role\" IN ('admin', 'member')" + } + }, + "isRLSEnabled": false + }, + "public.team_sending_controls": { + "name": "team_sending_controls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'normal'" + }, + "reason_code": { + "name": "reason_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'automatic'" + }, + "entered_at": { + "name": "entered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "evaluated_at": { + "name": "evaluated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "minimum_hold_until": { + "name": "minimum_hold_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "operator_user_id": { + "name": "operator_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "operator_reason": { + "name": "operator_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "overridden_at": { + "name": "overridden_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "clean_evaluation_days": { + "name": "clean_evaluation_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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": {}, + "foreignKeys": { + "team_sending_controls_team_id_teams_id_fk": { + "name": "team_sending_controls_team_id_teams_id_fk", + "tableFrom": "team_sending_controls", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "team_sending_controls_operator_user_id_user_id_fk": { + "name": "team_sending_controls_operator_user_id_user_id_fk", + "tableFrom": "team_sending_controls", + "tableTo": "user", + "columnsFrom": [ + "operator_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "team_sending_controls_team_id_unique": { + "name": "team_sending_controls_team_id_unique", + "nullsNotDistinct": false, + "columns": [ + "team_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "team_sending_controls_status_check": { + "name": "team_sending_controls_status_check", + "value": "\"team_sending_controls\".\"status\" IN ('normal', 'warned', 'marketing_paused', 'all_paused')" + }, + "team_sending_controls_source_check": { + "name": "team_sending_controls_source_check", + "value": "\"team_sending_controls\".\"source\" IN ('automatic', 'operator', 'abuse')" + }, + "team_sending_controls_clean_days_check": { + "name": "team_sending_controls_clean_days_check", + "value": "\"team_sending_controls\".\"clean_evaluation_days\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.teams": { + "name": "teams", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provisioning_request_hash": { + "name": "provisioning_request_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "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": { + "teams_organization_id_external_id_idx": { + "name": "teams_organization_id_external_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"teams\".\"external_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "teams_organization_id_organizations_id_fk": { + "name": "teams_organization_id_organizations_id_fk", + "tableFrom": "teams", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "teams_team_id_unique": { + "name": "teams_team_id_unique", + "nullsNotDistinct": false, + "columns": [ + "team_id" + ] + }, + "teams_id_organization_id_unique": { + "name": "teams_id_organization_id_unique", + "nullsNotDistinct": false, + "columns": [ + "id", + "organization_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "teams_team_id_check": { + "name": "teams_team_id_check", + "value": "\"teams\".\"team_id\" ~ '^team_'" + }, + "teams_status_check": { + "name": "teams_status_check", + "value": "\"teams\".\"status\" IN ('active', 'sending_suspended', 'archived')" + } + }, + "isRLSEnabled": false + }, + "public.transactional_emails": { + "name": "transactional_emails", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "txe_id": { + "name": "txe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "delivery_source_type": { + "name": "delivery_source_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outbox_id": { + "name": "outbox_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "esp_grant_id": { + "name": "esp_grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "to_email": { + "name": "to_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reply_to": { + "name": "reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template_id": { + "name": "template_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "html": { + "name": "html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "headers": { + "name": "headers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "contact_id": { + "name": "contact_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "track_opens": { + "name": "track_opens", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "track_clicks": { + "name": "track_clicks", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "open_count": { + "name": "open_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "click_count": { + "name": "click_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "transactional_emails_team_id_idempotency_key_idx": { + "name": "transactional_emails_team_id_idempotency_key_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"transactional_emails\".\"idempotency_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "transactional_emails_team_id_created_at_idx": { + "name": "transactional_emails_team_id_created_at_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "transactional_emails_team_id_status_idx": { + "name": "transactional_emails_team_id_status_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "transactional_emails_team_id_teams_id_fk": { + "name": "transactional_emails_team_id_teams_id_fk", + "tableFrom": "transactional_emails", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "transactional_emails_outbox_id_esp_configs_id_fk": { + "name": "transactional_emails_outbox_id_esp_configs_id_fk", + "tableFrom": "transactional_emails", + "tableTo": "esp_configs", + "columnsFrom": [ + "outbox_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "transactional_emails_esp_grant_id_esp_config_team_grants_id_fk": { + "name": "transactional_emails_esp_grant_id_esp_config_team_grants_id_fk", + "tableFrom": "transactional_emails", + "tableTo": "esp_config_team_grants", + "columnsFrom": [ + "esp_grant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "transactional_emails_contact_id_contacts_id_fk": { + "name": "transactional_emails_contact_id_contacts_id_fk", + "tableFrom": "transactional_emails", + "tableTo": "contacts", + "columnsFrom": [ + "contact_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "transactional_emails_txe_id_unique": { + "name": "transactional_emails_txe_id_unique", + "nullsNotDistinct": false, + "columns": [ + "txe_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "transactional_emails_txe_id_check": { + "name": "transactional_emails_txe_id_check", + "value": "\"transactional_emails\".\"txe_id\" ~ '^txe_'" + }, + "transactional_emails_delivery_pin_check": { + "name": "transactional_emails_delivery_pin_check", + "value": "(\n \"transactional_emails\".\"delivery_source_type\" = 'team'\n AND \"transactional_emails\".\"outbox_id\" IS NOT NULL\n AND \"transactional_emails\".\"esp_grant_id\" IS NULL\n ) OR (\n \"transactional_emails\".\"delivery_source_type\" = 'organization'\n AND \"transactional_emails\".\"outbox_id\" IS NOT NULL\n AND \"transactional_emails\".\"esp_grant_id\" IS NOT NULL\n )" + } + }, + "isRLSEnabled": false + }, + "public.trial_claims": { + "name": "trial_claims", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verified_email_fingerprint": { + "name": "verified_email_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "fingerprint_key_version": { + "name": "fingerprint_key_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "trial_key": { + "name": "trial_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkout_attempt_id": { + "name": "checkout_attempt_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'reserved'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "redeemed_at": { + "name": "redeemed_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": { + "trial_claims_user_trial_uidx": { + "name": "trial_claims_user_trial_uidx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "trial_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"trial_claims\".\"status\" <> 'released'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "trial_claims_email_trial_uidx": { + "name": "trial_claims_email_trial_uidx", + "columns": [ + { + "expression": "verified_email_fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "trial_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"trial_claims\".\"status\" <> 'released'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "trial_claims_checkout_attempt_idx": { + "name": "trial_claims_checkout_attempt_idx", + "columns": [ + { + "expression": "checkout_attempt_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "trial_claims_user_id_user_id_fk": { + "name": "trial_claims_user_id_user_id_fk", + "tableFrom": "trial_claims", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "trial_claims_organization_id_organizations_id_fk": { + "name": "trial_claims_organization_id_organizations_id_fk", + "tableFrom": "trial_claims", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "trial_claims_status_check": { + "name": "trial_claims_status_check", + "value": "\"trial_claims\".\"status\" IN ('reserved', 'redeemed', 'released')" + } + }, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_organization_id": { + "name": "default_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_default_organization_id_organizations_id_fk": { + "name": "user_default_organization_id_organizations_id_fk", + "tableFrom": "user", + "tableTo": "organizations", + "columnsFrom": [ + "default_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "auth_verification_identifier_idx": { + "name": "auth_verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.billing_catalog_revision_items": { + "name": "billing_catalog_revision_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "catalog_revision_id": { + "name": "catalog_revision_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "offer_key": { + "name": "offer_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_price_entry_id": { + "name": "billing_price_entry_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "billing_catalog_revision_items_revision_key_uidx": { + "name": "billing_catalog_revision_items_revision_key_uidx", + "columns": [ + { + "expression": "catalog_revision_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "offer_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_catalog_revision_items_revision_price_uidx": { + "name": "billing_catalog_revision_items_revision_price_uidx", + "columns": [ + { + "expression": "catalog_revision_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_price_entry_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "billing_catalog_revision_items_catalog_revision_id_billing_catalog_revisions_id_fk": { + "name": "billing_catalog_revision_items_catalog_revision_id_billing_catalog_revisions_id_fk", + "tableFrom": "billing_catalog_revision_items", + "tableTo": "billing_catalog_revisions", + "columnsFrom": [ + "catalog_revision_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "billing_catalog_revision_items_billing_price_entry_id_billing_price_entries_id_fk": { + "name": "billing_catalog_revision_items_billing_price_entry_id_billing_price_entries_id_fk", + "tableFrom": "billing_catalog_revision_items", + "tableTo": "billing_price_entries", + "columnsFrom": [ + "billing_price_entry_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.billing_catalog_revisions": { + "name": "billing_catalog_revisions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "checkout_provider": { + "name": "checkout_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending_verification'" + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "activated_at": { + "name": "activated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "retired_at": { + "name": "retired_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": { + "billing_catalog_revisions_active_provider_uidx": { + "name": "billing_catalog_revisions_active_provider_uidx", + "columns": [ + { + "expression": "checkout_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"billing_catalog_revisions\".\"status\" = 'active'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "billing_catalog_revisions_revision_unique": { + "name": "billing_catalog_revisions_revision_unique", + "nullsNotDistinct": false, + "columns": [ + "revision" + ] + } + }, + "policies": {}, + "checkConstraints": { + "billing_catalog_revisions_status_check": { + "name": "billing_catalog_revisions_status_check", + "value": "\"billing_catalog_revisions\".\"status\" IN ('pending_verification', 'active', 'retired', 'invalid', 'abandoned')" + }, + "billing_catalog_revisions_revision_check": { + "name": "billing_catalog_revisions_revision_check", + "value": "\"billing_catalog_revisions\".\"revision\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.billing_checkout_attempts": { + "name": "billing_checkout_attempts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "attempt_id": { + "name": "attempt_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billable_entity_id": { + "name": "billable_entity_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "payer_id": { + "name": "payer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payer_email": { + "name": "payer_email", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "return_url": { + "name": "return_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "catalog_revision": { + "name": "catalog_revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "offer_key": { + "name": "offer_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_plan": { + "name": "requested_plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_interval": { + "name": "requested_interval", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_price_entry_id": { + "name": "billing_price_entry_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "quoted_amount_minor": { + "name": "quoted_amount_minor", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "quoted_currency": { + "name": "quoted_currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_customer_id": { + "name": "billing_customer_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider_checkout_session_id": { + "name": "provider_checkout_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checkout_url_encrypted": { + "name": "checkout_url_encrypted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'creating'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_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": { + "billing_checkout_attempts_provider_session_uidx": { + "name": "billing_checkout_attempts_provider_session_uidx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_checkout_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"billing_checkout_attempts\".\"provider_checkout_session_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_checkout_attempts_idempotency_uidx": { + "name": "billing_checkout_attempts_idempotency_uidx", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_checkout_attempts_entity_nonterminal_uidx": { + "name": "billing_checkout_attempts_entity_nonterminal_uidx", + "columns": [ + { + "expression": "billable_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"billing_checkout_attempts\".\"status\" IN ('creating', 'open')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "billing_checkout_attempts_billable_entity_id_organizations_id_fk": { + "name": "billing_checkout_attempts_billable_entity_id_organizations_id_fk", + "tableFrom": "billing_checkout_attempts", + "tableTo": "organizations", + "columnsFrom": [ + "billable_entity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "billing_checkout_attempts_payer_id_user_id_fk": { + "name": "billing_checkout_attempts_payer_id_user_id_fk", + "tableFrom": "billing_checkout_attempts", + "tableTo": "user", + "columnsFrom": [ + "payer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "billing_checkout_attempts_billing_price_entry_id_billing_price_entries_id_fk": { + "name": "billing_checkout_attempts_billing_price_entry_id_billing_price_entries_id_fk", + "tableFrom": "billing_checkout_attempts", + "tableTo": "billing_price_entries", + "columnsFrom": [ + "billing_price_entry_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "billing_checkout_attempts_billing_customer_id_billing_provider_customers_id_fk": { + "name": "billing_checkout_attempts_billing_customer_id_billing_provider_customers_id_fk", + "tableFrom": "billing_checkout_attempts", + "tableTo": "billing_provider_customers", + "columnsFrom": [ + "billing_customer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "billing_checkout_attempts_attempt_id_unique": { + "name": "billing_checkout_attempts_attempt_id_unique", + "nullsNotDistinct": false, + "columns": [ + "attempt_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "billing_checkout_attempts_status_check": { + "name": "billing_checkout_attempts_status_check", + "value": "\"billing_checkout_attempts\".\"status\" IN ('creating', 'open', 'completed', 'expired', 'abandoned', 'conflicted')" + }, + "billing_checkout_attempts_amount_check": { + "name": "billing_checkout_attempts_amount_check", + "value": "\"billing_checkout_attempts\".\"quoted_amount_minor\" > 0" + }, + "billing_checkout_attempts_plan_check": { + "name": "billing_checkout_attempts_plan_check", + "value": "\"billing_checkout_attempts\".\"requested_plan\" IN ('starter', 'pro', 'business')" + }, + "billing_checkout_attempts_interval_check": { + "name": "billing_checkout_attempts_interval_check", + "value": "\"billing_checkout_attempts\".\"requested_interval\" IN ('month', 'year')" + } + }, + "isRLSEnabled": false + }, + "public.billing_plan_change_attempts": { + "name": "billing_plan_change_attempts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "change_id": { + "name": "change_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billable_entity_id": { + "name": "billable_entity_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "subscription_id": { + "name": "subscription_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payer_id": { + "name": "payer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current_catalog_revision": { + "name": "current_catalog_revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "current_billing_price_entry_id": { + "name": "current_billing_price_entry_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "current_plan": { + "name": "current_plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current_interval": { + "name": "current_interval", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_catalog_revision": { + "name": "target_catalog_revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "target_billing_price_entry_id": { + "name": "target_billing_price_entry_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_plan": { + "name": "target_plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_interval": { + "name": "target_interval", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_offer_key": { + "name": "target_offer_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effective_at": { + "name": "effective_at", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "proration_mode": { + "name": "proration_mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_payment_id": { + "name": "provider_payment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payment_url_encrypted": { + "name": "payment_url_encrypted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'creating'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_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": { + "billing_plan_change_attempts_idempotency_uidx": { + "name": "billing_plan_change_attempts_idempotency_uidx", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_plan_change_attempts_entity_nonterminal_uidx": { + "name": "billing_plan_change_attempts_entity_nonterminal_uidx", + "columns": [ + { + "expression": "billable_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"billing_plan_change_attempts\".\"status\" IN ('creating', 'pending')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "billing_plan_change_attempts_billable_entity_id_organizations_id_fk": { + "name": "billing_plan_change_attempts_billable_entity_id_organizations_id_fk", + "tableFrom": "billing_plan_change_attempts", + "tableTo": "organizations", + "columnsFrom": [ + "billable_entity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "billing_plan_change_attempts_subscription_id_billing_subscriptions_id_fk": { + "name": "billing_plan_change_attempts_subscription_id_billing_subscriptions_id_fk", + "tableFrom": "billing_plan_change_attempts", + "tableTo": "billing_subscriptions", + "columnsFrom": [ + "subscription_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "billing_plan_change_attempts_payer_id_user_id_fk": { + "name": "billing_plan_change_attempts_payer_id_user_id_fk", + "tableFrom": "billing_plan_change_attempts", + "tableTo": "user", + "columnsFrom": [ + "payer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "billing_plan_change_attempts_current_billing_price_entry_id_billing_price_entries_id_fk": { + "name": "billing_plan_change_attempts_current_billing_price_entry_id_billing_price_entries_id_fk", + "tableFrom": "billing_plan_change_attempts", + "tableTo": "billing_price_entries", + "columnsFrom": [ + "current_billing_price_entry_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "billing_plan_change_attempts_target_billing_price_entry_id_billing_price_entries_id_fk": { + "name": "billing_plan_change_attempts_target_billing_price_entry_id_billing_price_entries_id_fk", + "tableFrom": "billing_plan_change_attempts", + "tableTo": "billing_price_entries", + "columnsFrom": [ + "target_billing_price_entry_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "billing_plan_change_attempts_change_id_unique": { + "name": "billing_plan_change_attempts_change_id_unique", + "nullsNotDistinct": false, + "columns": [ + "change_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "billing_plan_change_attempts_status_check": { + "name": "billing_plan_change_attempts_status_check", + "value": "\"billing_plan_change_attempts\".\"status\" IN ('creating', 'pending', 'succeeded', 'failed', 'conflicted')" + }, + "billing_plan_change_attempts_effective_at_check": { + "name": "billing_plan_change_attempts_effective_at_check", + "value": "\"billing_plan_change_attempts\".\"effective_at\" IN ('immediately', 'next_billing_date')" + }, + "billing_plan_change_attempts_proration_mode_check": { + "name": "billing_plan_change_attempts_proration_mode_check", + "value": "\"billing_plan_change_attempts\".\"proration_mode\" IN ('prorated_immediately', 'do_not_bill')" + }, + "billing_plan_change_attempts_current_plan_check": { + "name": "billing_plan_change_attempts_current_plan_check", + "value": "\"billing_plan_change_attempts\".\"current_plan\" IN ('starter', 'pro', 'business')" + }, + "billing_plan_change_attempts_target_plan_check": { + "name": "billing_plan_change_attempts_target_plan_check", + "value": "\"billing_plan_change_attempts\".\"target_plan\" IN ('starter', 'pro', 'business')" + } + }, + "isRLSEnabled": false + }, + "public.billing_plan_states": { + "name": "billing_plan_states", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "billable_entity_id": { + "name": "billable_entity_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "active_subscription_id": { + "name": "active_subscription_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "projection_version": { + "name": "projection_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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": {}, + "foreignKeys": { + "billing_plan_states_billable_entity_id_organizations_id_fk": { + "name": "billing_plan_states_billable_entity_id_organizations_id_fk", + "tableFrom": "billing_plan_states", + "tableTo": "organizations", + "columnsFrom": [ + "billable_entity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "billing_plan_states_active_subscription_id_billing_subscriptions_id_fk": { + "name": "billing_plan_states_active_subscription_id_billing_subscriptions_id_fk", + "tableFrom": "billing_plan_states", + "tableTo": "billing_subscriptions", + "columnsFrom": [ + "active_subscription_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "billing_plan_states_billable_entity_id_unique": { + "name": "billing_plan_states_billable_entity_id_unique", + "nullsNotDistinct": false, + "columns": [ + "billable_entity_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.billing_price_entries": { + "name": "billing_price_entries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "offer_key": { + "name": "offer_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount_minor": { + "name": "amount_minor", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider_trial_days": { + "name": "provider_trial_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_product_id": { + "name": "provider_product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verified_at": { + "name": "verified_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": { + "billing_price_entries_provider_product_uidx": { + "name": "billing_price_entries_provider_product_uidx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_price_entries_offer_key_idx": { + "name": "billing_price_entries_offer_key_idx", + "columns": [ + { + "expression": "offer_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "billing_price_entries_amount_check": { + "name": "billing_price_entries_amount_check", + "value": "\"billing_price_entries\".\"amount_minor\" > 0" + }, + "billing_price_entries_trial_days_check": { + "name": "billing_price_entries_trial_days_check", + "value": "\"billing_price_entries\".\"provider_trial_days\" >= 0" + }, + "billing_price_entries_currency_check": { + "name": "billing_price_entries_currency_check", + "value": "\"billing_price_entries\".\"currency\" ~ '^[A-Z]{3}$'" + }, + "billing_price_entries_plan_check": { + "name": "billing_price_entries_plan_check", + "value": "\"billing_price_entries\".\"plan\" IN ('starter', 'pro', 'business')" + }, + "billing_price_entries_interval_check": { + "name": "billing_price_entries_interval_check", + "value": "\"billing_price_entries\".\"billing_interval\" IN ('month', 'year')" + } + }, + "isRLSEnabled": false + }, + "public.billing_provider_customers": { + "name": "billing_provider_customers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payer_id": { + "name": "payer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payer_email": { + "name": "payer_email", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider_customer_id": { + "name": "provider_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'creating'" + }, + "last_error": { + "name": "last_error", + "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": { + "billing_provider_customers_provider_payer_uidx": { + "name": "billing_provider_customers_provider_payer_uidx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "payer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_provider_customers_provider_customer_uidx": { + "name": "billing_provider_customers_provider_customer_uidx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"billing_provider_customers\".\"provider_customer_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_provider_customers_idempotency_uidx": { + "name": "billing_provider_customers_idempotency_uidx", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "billing_provider_customers_payer_id_user_id_fk": { + "name": "billing_provider_customers_payer_id_user_id_fk", + "tableFrom": "billing_provider_customers", + "tableTo": "user", + "columnsFrom": [ + "payer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "billing_provider_customers_status_check": { + "name": "billing_provider_customers_status_check", + "value": "\"billing_provider_customers\".\"status\" IN ('creating', 'active', 'conflicted')" + } + }, + "isRLSEnabled": false + }, + "public.billing_reconciliation_jobs": { + "name": "billing_reconciliation_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checkout_attempt_id": { + "name": "checkout_attempt_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "plan_change_attempt_id": { + "name": "plan_change_attempt_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "subscription_id": { + "name": "subscription_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider_customer_id": { + "name": "provider_customer_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "operation": { + "name": "operation", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'reconcile'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "available_at": { + "name": "available_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "worker_id": { + "name": "worker_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "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": { + "billing_reconciliation_jobs_live_checkout_uidx": { + "name": "billing_reconciliation_jobs_live_checkout_uidx", + "columns": [ + { + "expression": "checkout_attempt_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"billing_reconciliation_jobs\".\"checkout_attempt_id\" IS NOT NULL AND \"billing_reconciliation_jobs\".\"status\" IN ('pending', 'processing', 'failed')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_reconciliation_jobs_live_plan_change_uidx": { + "name": "billing_reconciliation_jobs_live_plan_change_uidx", + "columns": [ + { + "expression": "plan_change_attempt_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"billing_reconciliation_jobs\".\"plan_change_attempt_id\" IS NOT NULL AND \"billing_reconciliation_jobs\".\"status\" IN ('pending', 'processing', 'failed')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_reconciliation_jobs_live_subscription_uidx": { + "name": "billing_reconciliation_jobs_live_subscription_uidx", + "columns": [ + { + "expression": "subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"billing_reconciliation_jobs\".\"subscription_id\" IS NOT NULL AND \"billing_reconciliation_jobs\".\"status\" IN ('pending', 'processing', 'failed')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_reconciliation_jobs_live_customer_uidx": { + "name": "billing_reconciliation_jobs_live_customer_uidx", + "columns": [ + { + "expression": "provider_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"billing_reconciliation_jobs\".\"provider_customer_id\" IS NOT NULL AND \"billing_reconciliation_jobs\".\"status\" IN ('pending', 'processing', 'failed')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "billing_reconciliation_jobs_checkout_attempt_id_billing_checkout_attempts_id_fk": { + "name": "billing_reconciliation_jobs_checkout_attempt_id_billing_checkout_attempts_id_fk", + "tableFrom": "billing_reconciliation_jobs", + "tableTo": "billing_checkout_attempts", + "columnsFrom": [ + "checkout_attempt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "billing_reconciliation_jobs_plan_change_attempt_id_billing_plan_change_attempts_id_fk": { + "name": "billing_reconciliation_jobs_plan_change_attempt_id_billing_plan_change_attempts_id_fk", + "tableFrom": "billing_reconciliation_jobs", + "tableTo": "billing_plan_change_attempts", + "columnsFrom": [ + "plan_change_attempt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "billing_reconciliation_jobs_subscription_id_billing_subscriptions_id_fk": { + "name": "billing_reconciliation_jobs_subscription_id_billing_subscriptions_id_fk", + "tableFrom": "billing_reconciliation_jobs", + "tableTo": "billing_subscriptions", + "columnsFrom": [ + "subscription_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "billing_reconciliation_jobs_provider_customer_id_billing_provider_customers_id_fk": { + "name": "billing_reconciliation_jobs_provider_customer_id_billing_provider_customers_id_fk", + "tableFrom": "billing_reconciliation_jobs", + "tableTo": "billing_provider_customers", + "columnsFrom": [ + "provider_customer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "billing_reconciliation_jobs_exactly_one_subject": { + "name": "billing_reconciliation_jobs_exactly_one_subject", + "value": "((CASE WHEN \"billing_reconciliation_jobs\".\"checkout_attempt_id\" IS NOT NULL THEN 1 ELSE 0 END) + (CASE WHEN \"billing_reconciliation_jobs\".\"plan_change_attempt_id\" IS NOT NULL THEN 1 ELSE 0 END) + (CASE WHEN \"billing_reconciliation_jobs\".\"subscription_id\" IS NOT NULL THEN 1 ELSE 0 END) + (CASE WHEN \"billing_reconciliation_jobs\".\"provider_customer_id\" IS NOT NULL THEN 1 ELSE 0 END)) = 1" + }, + "billing_reconciliation_jobs_status_check": { + "name": "billing_reconciliation_jobs_status_check", + "value": "\"billing_reconciliation_jobs\".\"status\" IN ('pending', 'processing', 'failed', 'completed', 'quarantined')" + }, + "billing_reconciliation_jobs_operation_check": { + "name": "billing_reconciliation_jobs_operation_check", + "value": "\"billing_reconciliation_jobs\".\"operation\" = 'reconcile' OR (\"billing_reconciliation_jobs\".\"operation\" = 'cancellation' AND \"billing_reconciliation_jobs\".\"subscription_id\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.billing_subscriptions": { + "name": "billing_subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "billable_entity_id": { + "name": "billable_entity_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "billing_customer_id": { + "name": "billing_customer_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "payer_id": { + "name": "payer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin_checkout_attempt_id": { + "name": "origin_checkout_attempt_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_subscription_id": { + "name": "provider_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_product_id": { + "name": "provider_product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_price_entry_id": { + "name": "billing_price_entry_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "catalog_revision": { + "name": "catalog_revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "offer_key": { + "name": "offer_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "current_period_starts_at": { + "name": "current_period_starts_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "current_period_ends_at": { + "name": "current_period_ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "paid_through_at": { + "name": "paid_through_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "trial_ends_at": { + "name": "trial_ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_entitlement_source": { + "name": "is_entitlement_source", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "provider_occurred_at": { + "name": "provider_occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "provider_version": { + "name": "provider_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_observed_at": { + "name": "last_observed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_reconciled_at": { + "name": "last_reconciled_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": { + "billing_subscriptions_provider_subscription_uidx": { + "name": "billing_subscriptions_provider_subscription_uidx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_subscriptions_entity_source_uidx": { + "name": "billing_subscriptions_entity_source_uidx", + "columns": [ + { + "expression": "billable_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"billing_subscriptions\".\"is_entitlement_source\" = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "billing_subscriptions_billable_entity_id_organizations_id_fk": { + "name": "billing_subscriptions_billable_entity_id_organizations_id_fk", + "tableFrom": "billing_subscriptions", + "tableTo": "organizations", + "columnsFrom": [ + "billable_entity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "billing_subscriptions_billing_customer_id_billing_provider_customers_id_fk": { + "name": "billing_subscriptions_billing_customer_id_billing_provider_customers_id_fk", + "tableFrom": "billing_subscriptions", + "tableTo": "billing_provider_customers", + "columnsFrom": [ + "billing_customer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "billing_subscriptions_payer_id_user_id_fk": { + "name": "billing_subscriptions_payer_id_user_id_fk", + "tableFrom": "billing_subscriptions", + "tableTo": "user", + "columnsFrom": [ + "payer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "billing_subscriptions_origin_checkout_attempt_id_billing_checkout_attempts_id_fk": { + "name": "billing_subscriptions_origin_checkout_attempt_id_billing_checkout_attempts_id_fk", + "tableFrom": "billing_subscriptions", + "tableTo": "billing_checkout_attempts", + "columnsFrom": [ + "origin_checkout_attempt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "billing_subscriptions_billing_price_entry_id_billing_price_entries_id_fk": { + "name": "billing_subscriptions_billing_price_entry_id_billing_price_entries_id_fk", + "tableFrom": "billing_subscriptions", + "tableTo": "billing_price_entries", + "columnsFrom": [ + "billing_price_entry_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "billing_subscriptions_status_check": { + "name": "billing_subscriptions_status_check", + "value": "\"billing_subscriptions\".\"status\" IN ('pending', 'trialing', 'active', 'past_due', 'cancelled', 'expired')" + }, + "billing_subscriptions_plan_check": { + "name": "billing_subscriptions_plan_check", + "value": "\"billing_subscriptions\".\"plan\" IN ('starter', 'pro', 'business')" + }, + "billing_subscriptions_interval_check": { + "name": "billing_subscriptions_interval_check", + "value": "\"billing_subscriptions\".\"billing_interval\" IN ('month', 'year')" + } + }, + "isRLSEnabled": false + }, + "public.billing_webhook_events": { + "name": "billing_webhook_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_event_id": { + "name": "provider_event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "subscription_id": { + "name": "subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checkout_attempt_id": { + "name": "checkout_attempt_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload_encrypted": { + "name": "payload_encrypted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload_key_version": { + "name": "payload_key_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verified_key_version": { + "name": "verified_key_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_attempts": { + "name": "processing_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "available_at": { + "name": "available_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "worker_id": { + "name": "worker_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "received_at": { + "name": "received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "billing_webhook_events_provider_event_uidx": { + "name": "billing_webhook_events_provider_event_uidx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_webhook_events_queue_idx": { + "name": "billing_webhook_events_queue_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "billing_webhook_events_status_check": { + "name": "billing_webhook_events_status_check", + "value": "\"billing_webhook_events\".\"status\" IN ('pending', 'processing', 'processed', 'ignored', 'quarantined', 'failed')" + } + }, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/api/drizzle/meta/_journal.json b/apps/api/drizzle/meta/_journal.json index cd5eebee..c0828429 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -204,6 +204,13 @@ "when": 1793274088220, "tag": "0028_broadcast_archive", "breakpoints": true + }, + { + "idx": 29, + "version": "7", + "when": 1793275761730, + "tag": "0029_account_delete", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/api/src/account/delete.routes.test.ts b/apps/api/src/account/delete.routes.test.ts new file mode 100644 index 00000000..b8adac35 --- /dev/null +++ b/apps/api/src/account/delete.routes.test.ts @@ -0,0 +1,547 @@ +import { afterEach, beforeEach, describe, expect, it, mock, vi } from "bun:test"; +import { eq, inArray } from "drizzle-orm"; +import express from "express"; + +const authState = { userId: "", authKind: "session" as string }; +const WEB = "http://localhost:3000"; +process.env.WEB_CLIENT = WEB; + +mock.module("../db/client", async () => { + const { makeTestDb } = await import("../test/db"); + return { db: await makeTestDb() }; +}); +mock.module("../auth/middleware", () => ({ + requireAuth: (req: any, _res: any, next: () => void) => { + req.userId = authState.userId; + req.authKind = authState.authKind; + next(); + }, +})); +const sendVerificationOTP = vi.fn(async () => ({ success: true })); +const checkVerificationOTP = vi.fn(async ({ body }: any) => { + if (body.otp !== "123456") throw new Error("INVALID_OTP"); + return { success: true }; +}); +const realAuth = await import("../auth/better-auth"); +mock.module("../auth/better-auth", () => ({ + ...realAuth, + auth: { ...realAuth.auth, api: { sendVerificationOTP, checkVerificationOTP } }, +})); +mock.module("../media/service", () => ({ deleteMedia: vi.fn() })); +mock.module("../media/deletion-queue", () => ({ queueMediaLitDeletion: vi.fn() })); +const captureEvent = vi.fn(); +const realObservability = await import("../observability"); +mock.module("../observability", () => ({ ...realObservability, captureEvent })); + +const { db } = await import("../db/client"); +const schema = await import("../db/schema"); +const { createUploadedMedia } = await import("../media/queries"); +const { deleteMedia } = await import("../media/service"); +const { createTeam } = await import("../team/queries"); +const { truncateAll } = await import("../test/db"); +const { + activateFakeCatalog, + clearCloudFakeBillingEnv, + seedPaidSubscription, + useCloudFakeBillingEnv, +} = await import("../test/billing"); +type TestDb = import("../test/db").TestDb; +const { requestApp } = await import("../test/http"); +const { addOrganizationMemberByEmail, createOrganization, ensureDefaultOrganization } = + await import("../organization/queries"); +const { resolveAuth } = await import("../auth/resolve-auth"); +const { default: accountRoutes } = await import("./routes"); + +const tdb = db as unknown as TestDb; +const deleteMediaMock = deleteMedia as unknown as ReturnType; + +function app() { + const instance = express(); + instance.use(express.json()); + instance.use(accountRoutes); + return instance; +} + +const sessionHeaders = { + origin: WEB, + cookie: "sendlit_csrf=csrf-token", + "x-sendlit-csrf": "csrf-token", + "content-type": "application/json", +}; + +function deleteRequest(body: { email: string; code: string }) { + return requestApp(app(), "/account", { + method: "DELETE", + headers: sessionHeaders, + body: JSON.stringify(body), + }); +} + +async function insertUser(name: string) { + const id = crypto.randomUUID(); + const [row] = await tdb + .insert(schema.user) + .values({ + id, + name, + email: `${name.toLowerCase()}-${id}@example.com`, + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning(); + const now = new Date(); + await tdb.insert(schema.session).values({ + id: `session-${id}`, + token: `token-${id}`, + userId: id, + expiresAt: new Date(Date.now() + 86_400_000), + createdAt: now, + updatedAt: now, + }); + await tdb.insert(schema.account).values({ + id: `account-${id}`, + accountId: id, + providerId: "credential", + userId: id, + createdAt: now, + updatedAt: now, + }); + await tdb.insert(schema.verification).values({ + id: `verification-${id}`, + identifier: `sign-in-otp-${row.email}`, + value: "hashed:0", + expiresAt: new Date(Date.now() + 60_000), + createdAt: now, + updatedAt: now, + }); + return row; +} + +/** An organization with a team holding contacts, mail history, media and a + * suppression, so erasure can be checked against all of them. */ +async function seedOrganization(owner: { id: string }, name: string) { + const organization = await createOrganization(owner.id, name); + const team = await createTeam({ + organizationId: organization.id, + creatorUserId: owner.id, + name: `${name} team`, + }); + const recipient = `reader-${name}@example.com`; + await tdb.insert(schema.contacts).values([ + { + teamId: team.id, + email: recipient, + name: "Reader", + unsubscribeToken: `unsub-${name}-1`, + }, + { + teamId: team.id, + email: `second-${name}@example.com`, + unsubscribeToken: `unsub-${name}-2`, + }, + ]); + const mediaLitIds = [`${name}-a`, `${name}-b`]; + for (const mediaLitId of mediaLitIds) { + await createUploadedMedia({ + teamId: team.id, + mediaLitId, + url: `https://cdn.test/${mediaLitId}.png`, + }); + } + const [esp] = await tdb + .insert(schema.espConfigs) + .values({ + ownerScope: "team", + teamId: team.id, + name: "Team", + host: "smtp.example.com", + encryptedSecret: "secret", + status: "draft", + }) + .returning(); + await tdb.insert(schema.outboundMessages).values({ + teamId: team.id, + deliverySourceType: "team", + espConfigId: esp.id, + sourceType: "campaign", + recipientEmail: recipient, + normalizedRecipient: recipient, + }); + await tdb.insert(schema.transactionalEmails).values({ + teamId: team.id, + deliverySourceType: "team", + outboxId: esp.id, + toEmail: recipient, + subject: "Receipt", + html: "

Hi Reader

", + variables: { name: "Reader" }, + }); + await tdb.insert(schema.emailSuppressions).values({ + teamId: team.id, + recipientEmail: recipient, + normalizedRecipient: recipient, + recipientHash: `hash-${name}`, + hashKeyVersion: 1, + reason: "hard_bounce", + }); + const [form] = await tdb + .insert(schema.signupForms) + .values({ teamId: team.id, name: "Newsletter" }) + .returning(); + await tdb.insert(schema.signupConfirmations).values({ + token: `confirm-${name}`, + formId: form.id, + email: recipient, + expiresAt: new Date(Date.now() + 60_000), + }); + return { organization, team, mediaLitIds, recipient }; +} + +async function organizationStatus(id: string) { + const [row] = await tdb + .select({ status: schema.organizations.status }) + .from(schema.organizations) + .where(eq(schema.organizations.id, id)); + return row?.status; +} + +async function userRow(id: string) { + const [row] = await tdb.select().from(schema.user).where(eq(schema.user.id, id)); + return row; +} + +beforeEach(async () => { + process.env.WEB_CLIENT = WEB; + await truncateAll(tdb); + deleteMediaMock.mockReset(); + captureEvent.mockReset(); + sendVerificationOTP.mockClear(); + checkVerificationOTP.mockClear(); + authState.userId = ""; + authState.authKind = "session"; +}); + +afterEach(() => { + clearCloudFakeBillingEnv(); +}); + +describe("GET /account/deletion", () => { + it("lists what happens to each organization", async () => { + const owner = await insertUser("Owner"); + const partner = await insertUser("Partner"); + const solo = await seedOrganization(owner, "solo"); + const shared = await seedOrganization(partner, "shared"); + await addOrganizationMemberByEmail(shared.organization.id, owner.email, "owner"); + const crowded = await seedOrganization(owner, "crowded"); + await addOrganizationMemberByEmail(crowded.organization.id, partner.email, "admin"); + authState.userId = owner.id; + + const response = await requestApp(app(), "/account/deletion"); + + expect(response.status).toBe(200); + const body = response.json(); + expect(body.email).toBe(owner.email); + expect(body.blocked).toBe(true); + const byId = Object.fromEntries( + body.organizations.map((item: any) => [item.organizationId, item]), + ); + expect(byId[solo.organization.organizationId]).toMatchObject({ + action: "delete", + blocker: null, + teamCount: 1, + }); + expect(byId[shared.organization.organizationId]).toMatchObject({ + action: "leave", + blocker: null, + }); + expect(byId[crowded.organization.organizationId]).toMatchObject({ + action: "delete", + blocker: "last_organization_owner", + }); + }); + + it("is for dashboard sessions only", async () => { + const owner = await insertUser("Owner"); + authState.userId = owner.id; + authState.authKind = "oauth"; + + const response = await requestApp(app(), "/account/deletion"); + + expect(response.status).toBe(403); + expect(response.json()).toEqual({ error: "human_session_required" }); + }); +}); + +describe("POST /account/deletion/code", () => { + it("emails a confirmation code to the account", async () => { + const owner = await insertUser("Owner"); + authState.userId = owner.id; + + const response = await requestApp(app(), "/account/deletion/code", { + method: "POST", + headers: sessionHeaders, + }); + + expect(response.status).toBe(204); + expect(sendVerificationOTP).toHaveBeenCalledWith({ + body: { email: owner.email, type: "email-verification" }, + }); + }); + + it("needs the dashboard's CSRF token", async () => { + const owner = await insertUser("Owner"); + authState.userId = owner.id; + + const response = await requestApp(app(), "/account/deletion/code", { + method: "POST", + headers: { origin: WEB }, + }); + + expect(response.status).toBe(403); + expect(sendVerificationOTP).not.toHaveBeenCalled(); + }); +}); + +describe("DELETE /account", () => { + it("deletes sole-owned organizations, leaves shared ones and erases the person", async () => { + const owner = await insertUser("Owner"); + const partner = await insertUser("Partner"); + const solo = await seedOrganization(owner, "solo"); + const shared = await seedOrganization(partner, "shared"); + await addOrganizationMemberByEmail(shared.organization.id, owner.email, "owner"); + await tdb.insert(schema.teamMembers).values({ + teamId: shared.team.id, + userId: owner.id, + role: "member", + }); + await tdb.insert(schema.abuseFlags).values({ + organizationId: solo.organization.id, + userId: owner.id, + kind: "signup", + severity: "low", + reason: "Signed up with a free email provider", + details: { email: owner.email, ip: "203.0.113.9", riskScore: 40 }, + }); + authState.userId = owner.id; + + const response = await deleteRequest({ email: owner.email, code: "123456" }); + + expect(response.status).toBe(204); + expect(String(response.headers["set-cookie"])).toContain("sendlit.session_token=;"); + + // The sole-owned organization is deleted with its MediaLit files... + expect(await organizationStatus(solo.organization.id)).toBe("closed"); + expect(deleteMediaMock.mock.calls.map(([id]) => id).sort()).toEqual( + solo.mediaLitIds, + ); + const media = await tdb.select().from(schema.media); + expect(media.map((row) => row.mediaLitId).sort()).toEqual(shared.mediaLitIds); + + // ...and its contacts' personal data erased; suppression hashes stay. + const soloContacts = await tdb + .select() + .from(schema.contacts) + .where(eq(schema.contacts.teamId, solo.team.id)); + expect(soloContacts).toHaveLength(0); + const [suppression] = await tdb + .select() + .from(schema.emailSuppressions) + .where(eq(schema.emailSuppressions.teamId, solo.team.id)); + expect(suppression).toMatchObject({ + recipientEmail: null, + normalizedRecipient: null, + recipientHash: "hash-solo", + active: true, + }); + const [outbound] = await tdb + .select() + .from(schema.outboundMessages) + .where(eq(schema.outboundMessages.teamId, solo.team.id)); + expect(outbound.recipientEmail).toBe(""); + const [transactional] = await tdb + .select() + .from(schema.transactionalEmails) + .where(eq(schema.transactionalEmails.teamId, solo.team.id)); + expect(transactional).toMatchObject({ toEmail: "", html: null, variables: {} }); + const confirmations = await tdb.select().from(schema.signupConfirmations); + expect(confirmations.map((row) => row.token)).toEqual(["confirm-shared"]); + + // The shared organization keeps everything; only the membership goes. + expect(await organizationStatus(shared.organization.id)).toBe("active"); + const sharedContacts = await tdb + .select() + .from(schema.contacts) + .where(eq(schema.contacts.teamId, shared.team.id)); + expect(sharedContacts).toHaveLength(2); + const [sharedSuppression] = await tdb + .select() + .from(schema.emailSuppressions) + .where(eq(schema.emailSuppressions.teamId, shared.team.id)); + expect(sharedSuppression.recipientEmail).toBe(shared.recipient); + const memberships = await tdb + .select() + .from(schema.organizationMembers) + .where(eq(schema.organizationMembers.userId, owner.id)); + expect(memberships).toHaveLength(0); + const teamMemberships = await tdb + .select() + .from(schema.teamMembers) + .where(eq(schema.teamMembers.userId, owner.id)); + expect(teamMemberships).toHaveLength(0); + const audit = await tdb + .select() + .from(schema.organizationAuditEvents) + .where(eq(schema.organizationAuditEvents.organizationId, shared.organization.id)); + expect(audit.map((event) => event.action)).toContain( + "organization.member_account_deleted", + ); + + // The person is gone: identity erased, signed out everywhere. + const erased = await userRow(owner.id); + expect(erased.name).toBe("Deleted user"); + expect(erased.email).not.toContain("owner"); + expect(erased.deletedAt).toBeInstanceOf(Date); + for (const table of [schema.session, schema.account]) { + const rows = await tdb + .select() + .from(table) + .where(inArray(table.userId, [owner.id, partner.id])); + expect(rows.map((row) => row.userId)).toEqual([partner.id]); + } + const codes = await tdb.select().from(schema.verification); + expect(codes.map((row) => row.identifier)).toEqual([ + `sign-in-otp-${partner.email}`, + ]); + const [flag] = await tdb.select().from(schema.abuseFlags); + expect(flag.details).toEqual({ riskScore: 40 }); + + // The other person is untouched. + expect((await userRow(partner.id)).email).toBe(partner.email); + + expect(captureEvent).toHaveBeenCalledWith( + expect.objectContaining({ + event: "account_deleted", + properties: { + user_id: owner.id, + organizations_deleted_count: 1, + organizations_left_count: 1, + contacts_erased_count: 2, + count: 2, + media_failed_count: 0, + }, + }), + ); + }); + + it("stops the deleted account's credentials from resolving", async () => { + const owner = await insertUser("Owner"); + await seedOrganization(owner, "solo"); + authState.userId = owner.id; + await deleteRequest({ email: owner.email, code: "123456" }); + + const resolved = await resolveAuth( + { headers: { cookie: "sendlit.session_token=whatever" } }, + { + resolveSession: async () => ({ + status: "authenticated", + identity: { method: "session", subject: owner.id }, + }), + } as any, + ).catch(() => ({ status: "unauthorized" })); + expect(resolved.status).not.toBe("authenticated"); + expect(await ensureDefaultOrganization(owner.id)).toBeNull(); + }); + + it("lets the same email sign up again as a new, empty account", async () => { + const owner = await insertUser("Owner"); + await seedOrganization(owner, "solo"); + authState.userId = owner.id; + await deleteRequest({ email: owner.email, code: "123456" }); + + const [fresh] = await tdb + .insert(schema.user) + .values({ + id: crypto.randomUUID(), + name: "Owner again", + email: owner.email, + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning(); + const organization = await ensureDefaultOrganization(fresh.id); + + expect(organization).not.toBeNull(); + const teams = await tdb + .select() + .from(schema.teams) + .where(eq(schema.teams.organizationId, organization!.id)); + expect(teams).toHaveLength(1); + const contacts = await tdb + .select() + .from(schema.contacts) + .where(eq(schema.contacts.teamId, teams[0].id)); + expect(contacts).toHaveLength(0); + }); + + it("is blocked while a sole-owned organization has a paid subscription", async () => { + useCloudFakeBillingEnv(); + await activateFakeCatalog(); + const owner = await insertUser("Owner"); + const solo = await seedOrganization(owner, "solo"); + const paid = solo.organization; + await seedPaidSubscription(tdb, { organizationId: paid.id, payerId: owner.id }); + authState.userId = owner.id; + + const response = await deleteRequest({ email: owner.email, code: "123456" }); + + expect(response.status).toBe(409); + const body = response.json(); + expect(body.error).toBe("account_deletion_blocked"); + expect(body.organizations).toContainEqual( + expect.objectContaining({ + organizationId: paid.organizationId, + blocker: "active_subscription_exists", + }), + ); + // Nothing changed. + expect(await organizationStatus(paid.id)).toBe("active"); + expect(deleteMediaMock).not.toHaveBeenCalled(); + expect((await userRow(owner.id)).deletedAt).toBeNull(); + }); + + it("is blocked while other members depend on them as the last owner", async () => { + const owner = await insertUser("Owner"); + const admin = await insertUser("Admin"); + const solo = await seedOrganization(owner, "solo"); + await addOrganizationMemberByEmail(solo.organization.id, admin.email, "admin"); + authState.userId = owner.id; + + const response = await deleteRequest({ email: owner.email, code: "123456" }); + + expect(response.status).toBe(409); + expect(response.json().organizations[0].blocker).toBe("last_organization_owner"); + expect(await organizationStatus(solo.organization.id)).toBe("active"); + }); + + it("needs the typed email and a valid code", async () => { + const owner = await insertUser("Owner"); + const solo = await seedOrganization(owner, "solo"); + authState.userId = owner.id; + + const mismatch = await deleteRequest({ + email: "someone@example.com", + code: "123456", + }); + expect(mismatch.status).toBe(400); + expect(mismatch.json()).toEqual({ error: "email_mismatch" }); + + const wrongCode = await deleteRequest({ email: owner.email, code: "000000" }); + expect(wrongCode.status).toBe(400); + expect(wrongCode.json()).toEqual({ error: "invalid_code" }); + + expect(await organizationStatus(solo.organization.id)).toBe("active"); + expect((await userRow(owner.id)).deletedAt).toBeNull(); + }); +}); diff --git a/apps/api/src/account/delete.ts b/apps/api/src/account/delete.ts new file mode 100644 index 00000000..28460bd5 --- /dev/null +++ b/apps/api/src/account/delete.ts @@ -0,0 +1,266 @@ +import { and, eq, inArray, ne, sql } from "drizzle-orm"; +import { db } from "../db/client"; +import { + abuseFlags, + account, + oauthAccessToken, + oauthClient, + oauthConsent, + oauthRefreshToken, + organizationMembers, + organizations, + session, + teamMembers, + teams, + user, + verification, +} from "../db/schema"; +import { captureEvent } from "../observability"; +import { recordOrganizationAuditEvent } from "../organization/audit"; +import { deleteOrganization } from "../organization/delete"; +import { + isActiveBillingManager, + organizationCloseBlocker, + removeOrganizationMember, +} from "../organization/queries"; +import { eraseOrganizationPersonalData } from "./erase"; + +/** What deleting the account does to one organization the person is in. */ +export type AccountOrganizationAction = + /** They are its last owner: it is deleted with its contacts' data. */ + | "delete" + /** Someone else stays in charge: only their membership goes. */ + | "leave"; + +export type AccountDeletionBlocker = + /** A paid subscription is running on an organization that would be deleted. */ + | "active_subscription_exists" + | "billing_checkout_pending" + /** They are the last owner and other people are still members. */ + | "last_organization_owner" + /** They pay for a running subscription of an organization they would leave. */ + | "billing_manager_required"; + +export interface AccountDeletionOrganization { + /** Internal id; routes map it to the public `org_` id. */ + id: string; + organizationId: string; + name: string; + status: string; + role: "owner" | "admin" | "member"; + action: AccountOrganizationAction; + teamCount: number; + blocker: AccountDeletionBlocker | null; +} + +export interface AccountDeletionPreview { + organizations: AccountDeletionOrganization[]; + blocked: boolean; +} + +export class AccountDeletionBlockedError extends Error { + constructor(readonly preview: AccountDeletionPreview) { + super("account_deletion_blocked"); + this.name = "AccountDeletionBlockedError"; + } +} + +/** + * Lists every organization the person belongs to with what deleting their + * account does to it, and what (if anything) has to happen first. + * Organizations that are already deleted (closed) are still listed when + * they were the last owner, because their contacts' data is erased too. + */ +export async function previewAccountDeletion( + userId: string, +): Promise { + const memberships = await db + .select({ + id: organizations.id, + organizationId: organizations.organizationId, + name: organizations.name, + status: organizations.status, + role: organizationMembers.role, + }) + .from(organizationMembers) + .innerJoin(organizations, eq(organizations.id, organizationMembers.organizationId)) + .where(eq(organizationMembers.userId, userId)) + .orderBy(organizations.createdAt); + + const result: AccountDeletionOrganization[] = []; + for (const membership of memberships) { + const others = await db + .select({ role: organizationMembers.role }) + .from(organizationMembers) + .where( + and( + eq(organizationMembers.organizationId, membership.id), + ne(organizationMembers.userId, userId), + ), + ); + const lastOwner = + membership.role === "owner" && !others.some((other) => other.role === "owner"); + const closed = membership.status === "closed"; + const [{ count }] = await db + .select({ count: sql`count(*)::int` }) + .from(teams) + .where(eq(teams.organizationId, membership.id)); + + let blocker: AccountDeletionBlocker | null = null; + if (!closed) { + if (lastOwner) { + blocker = + others.length > 0 + ? "last_organization_owner" + : await organizationCloseBlocker(membership.id); + } else if (await isActiveBillingManager(db, membership.id, userId)) { + blocker = "billing_manager_required"; + } + } + result.push({ + ...membership, + role: membership.role as AccountDeletionOrganization["role"], + action: lastOwner ? "delete" : "leave", + teamCount: count, + blocker, + }); + } + return { + organizations: result, + blocked: result.some((organization) => organization.blocker !== null), + }; +} + +export interface AccountDeletionResult { + organizationsDeleted: number; + organizationsLeft: number; + contactsErased: number; + mediaDeleted: number; + mediaFailed: number; +} + +/** + * Deletes a person's account (#134, ADR 0009). Throws + * `AccountDeletionBlockedError` and changes nothing while billing or another + * member depends on them. Otherwise: + * + * 1. every organization they are the last owner of is deleted + * (`deleteOrganization`) and its contacts' personal data erased; + * 2. they leave every other organization and team; + * 3. their sessions, sign-in methods, OAuth tokens, consents and clients + * and pending codes are deleted, so they are signed out everywhere; + * 4. the `user` row becomes an anonymous tombstone: name and email erased, + * `deletedAt` set. Its id stays in billing, audit and trial records. + * + * Signing in again with the same email creates a new, empty account. + */ +export async function deleteAccount(userId: string): Promise { + const [identity] = await db.select().from(user).where(eq(user.id, userId)).limit(1); + if (!identity || identity.deletedAt) throw new Error("account_not_found"); + + const preview = await previewAccountDeletion(userId); + if (preview.blocked) throw new AccountDeletionBlockedError(preview); + + const actor = { type: "user" as const, id: userId }; + const result: AccountDeletionResult = { + organizationsDeleted: 0, + organizationsLeft: 0, + contactsErased: 0, + mediaDeleted: 0, + mediaFailed: 0, + }; + for (const organization of preview.organizations) { + if (organization.action === "leave") { + await removeOrganizationMember(organization.id, userId); + await recordOrganizationAuditEvent(db, { + organizationId: organization.id, + actor, + action: "organization.member_account_deleted", + }); + result.organizationsLeft += 1; + continue; + } + const [current] = await db + .select({ status: organizations.status }) + .from(organizations) + .where(eq(organizations.id, organization.id)); + if (current?.status !== "closed") { + try { + const deleted = await deleteOrganization(organization.id, actor); + result.mediaDeleted += deleted.mediaDeleted; + result.mediaFailed += deleted.mediaFailed; + } catch (error: any) { + // Billing changed since the preview: report it like the preview would. + if ( + error?.message === "active_subscription_exists" || + error?.message === "billing_checkout_pending" + ) { + throw new AccountDeletionBlockedError(await previewAccountDeletion(userId)); + } + throw error; + } + } + const erased = await eraseOrganizationPersonalData(organization.id); + result.contactsErased += erased.contactsErased; + result.organizationsDeleted += 1; + } + + await eraseIdentity(identity); + + captureEvent({ + event: "account_deleted", + source: "account", + properties: { + user_id: userId, + organizations_deleted_count: result.organizationsDeleted, + organizations_left_count: result.organizationsLeft, + contacts_erased_count: result.contactsErased, + count: result.mediaDeleted, + media_failed_count: result.mediaFailed, + }, + }); + return result; +} + +const OTP_TYPES = ["sign-in", "email-verification", "forget-password", "change-email"]; + +async function eraseIdentity(identity: typeof user.$inferSelect): Promise { + const now = new Date(); + await db.transaction(async (tx) => { + await tx.delete(teamMembers).where(eq(teamMembers.userId, identity.id)); + await tx + .delete(organizationMembers) + .where(eq(organizationMembers.userId, identity.id)); + await tx.delete(oauthAccessToken).where(eq(oauthAccessToken.userId, identity.id)); + await tx.delete(oauthRefreshToken).where(eq(oauthRefreshToken.userId, identity.id)); + await tx.delete(oauthConsent).where(eq(oauthConsent.userId, identity.id)); + await tx.delete(oauthClient).where(eq(oauthClient.userId, identity.id)); + await tx.delete(session).where(eq(session.userId, identity.id)); + await tx.delete(account).where(eq(account.userId, identity.id)); + await tx + .delete(verification) + .where( + inArray(verification.identifier, [ + identity.email, + ...OTP_TYPES.map((type) => `${type}-otp-${identity.email}`), + ]), + ); + // Abuse review keeps the flag and the opaque user id, not the person. + await tx + .update(abuseFlags) + .set({ details: sql`${abuseFlags.details} - 'email' - 'ip'` }) + .where(eq(abuseFlags.userId, identity.id)); + await tx + .update(user) + .set({ + name: "Deleted user", + email: `deleted-${identity.id}@deleted.invalid`, + emailVerified: false, + image: null, + defaultOrganizationId: null, + deletedAt: now, + updatedAt: now, + }) + .where(eq(user.id, identity.id)); + }); +} diff --git a/apps/api/src/account/erase.ts b/apps/api/src/account/erase.ts new file mode 100644 index 00000000..5f2fc50c --- /dev/null +++ b/apps/api/src/account/erase.ts @@ -0,0 +1,122 @@ +import { eq, inArray, or } from "drizzle-orm"; +import { db } from "../db/client"; +import { + contacts, + emailDeliveryEvents, + espFeedbackConnections, + espWebhookReceipts, + eventDefinitions, + reputationConnections, + signupConfirmations, + signupForms, + teams, + transactionalEmails, +} from "../db/schema"; +import { anonymizeTeamRecipientsForPrivacyDeletion } from "../delivery-feedback/retention"; + +const BATCH_SIZE = 500; + +/** + * Erases the personal data an organization holds about its contacts, when + * its last owner deletes their account (ADR 0009). The organization must + * already be closed (`deleteOrganization`); this only removes people's data: + * + * - contacts, with their custom field values, events, enrolments and + * per-contact delivery rows (all cascade from `contacts`); + * - pending and past signup confirmations; + * - recipient addresses, rendered bodies and merge variables of + * transactional emails; + * - recipient addresses on outbound messages, delivery events and + * suppressions. Suppression hashes stay so the address stays blocked if + * the data ever comes back; + * - raw ESP webhook payloads and the last properties seen per event; + * - reputation connections (Google account label and refresh token). + * + * Billing, audit and aggregate reporting rows have no personal data and + * stay with the closed organization. + */ +export async function eraseOrganizationPersonalData( + organizationId: string, +): Promise<{ contactsErased: number }> { + const teamIds = ( + await db + .select({ id: teams.id }) + .from(teams) + .where(eq(teams.organizationId, organizationId)) + ).map((team) => team.id); + + let contactsErased = 0; + for (const teamId of teamIds) { + contactsErased += await deleteTeamContacts(teamId); + const forms = db + .select({ id: signupForms.id }) + .from(signupForms) + .where(eq(signupForms.teamId, teamId)); + await db + .delete(signupConfirmations) + .where(inArray(signupConfirmations.formId, forms)); + await db + .update(transactionalEmails) + .set({ + toEmail: "", + replyTo: null, + html: null, + text: null, + variables: {}, + headers: null, + contactId: null, + }) + .where(eq(transactionalEmails.teamId, teamId)); + await anonymizeTeamRecipientsForPrivacyDeletion(teamId); + await db + .update(eventDefinitions) + .set({ lastProperties: null }) + .where(eq(eventDefinitions.teamId, teamId)); + await db + .delete(reputationConnections) + .where(eq(reputationConnections.teamId, teamId)); + } + + const connectionOwner = + teamIds.length > 0 + ? or( + eq(espFeedbackConnections.organizationId, organizationId), + inArray(espFeedbackConnections.teamId, teamIds), + ) + : eq(espFeedbackConnections.organizationId, organizationId); + const connections = db + .select({ id: espFeedbackConnections.id }) + .from(espFeedbackConnections) + .where(connectionOwner); + await db + .update(espWebhookReceipts) + .set({ encryptedPayload: null, safeHeaders: {} }) + .where(inArray(espWebhookReceipts.connectionId, connections)); + // Events the feedback pipeline never matched to a team. + await db + .update(emailDeliveryEvents) + .set({ recipientEmail: null, normalizedRecipient: null }) + .where(inArray(emailDeliveryEvents.connectionId, connections)); + + return { contactsErased }; +} + +/** Deletes a team's contacts in bounded batches; their dependants cascade. */ +async function deleteTeamContacts(teamId: string): Promise { + let total = 0; + for (;;) { + const batch = await db + .select({ id: contacts.id }) + .from(contacts) + .where(eq(contacts.teamId, teamId)) + .limit(BATCH_SIZE); + if (batch.length === 0) return total; + await db.delete(contacts).where( + inArray( + contacts.id, + batch.map((row) => row.id), + ), + ); + total += batch.length; + } +} diff --git a/apps/api/src/account/routes.ts b/apps/api/src/account/routes.ts new file mode 100644 index 00000000..2d50c9d6 --- /dev/null +++ b/apps/api/src/account/routes.ts @@ -0,0 +1,131 @@ +import { contract } from "@sendlit/api-contract"; +import { createExpressEndpoints, initServer } from "@ts-rest/express"; +import { Router } from "express"; +import rateLimit from "express-rate-limit"; +import { auth, SESSION_COOKIE_NAME } from "../auth/better-auth"; +import { requireAuth } from "../auth/middleware"; +import { dashboardSessionBoundary, ensureCsrfCookie } from "../billing/security"; +import { getUser } from "../user/queries"; +import { + AccountDeletionBlockedError, + type AccountDeletionPreview, + deleteAccount, + previewAccountDeletion, +} from "./delete"; + +/** The Better Auth email OTP type reserved for confirming account deletion. */ +const DELETION_CODE_TYPE = "email-verification" as const; + +const router = Router(); +router.use("/account", requireAuth); +router.use("/account", (req, res, next) => { + if ((req as any).authKind !== "session") { + res.status(403).json({ error: "human_session_required" }); + return; + } + ensureCsrfCookie(req, res); + next(); +}); +router.post( + "/account/deletion/code", + rateLimit({ + windowMs: 60_000, + max: 3, + standardHeaders: true, + legacyHeaders: false, + message: { error: "too_many_requests" }, + }), +); + +const s = initServer(); + +function serializePreview(preview: AccountDeletionPreview) { + return { + organizations: preview.organizations.map((organization) => ({ + organizationId: organization.organizationId, + name: organization.name, + status: organization.status as + | "pending_payment" + | "active" + | "suspended" + | "abandoned" + | "closed", + role: organization.role, + action: organization.action, + teamCount: organization.teamCount, + blocker: organization.blocker, + })), + blocked: preview.blocked, + }; +} + +async function currentUser(req: any) { + const identity = await getUser(req.userId); + return identity && !identity.deletedAt ? identity : null; +} + +const impl = s.router(contract.account, { + getDeletion: async ({ req }) => { + const identity = await currentUser(req); + if (!identity) return { status: 401, body: { error: "unauthorized" } }; + const preview = await previewAccountDeletion(identity.id); + return { + status: 200, + body: { email: identity.email, ...serializePreview(preview) }, + }; + }, + sendDeletionCode: async ({ req, res }) => { + const boundary = dashboardSessionBoundary(req, res); + if (boundary) return boundary as any; + const identity = await currentUser(req); + if (!identity) return { status: 401, body: { error: "unauthorized" } }; + await auth.api.sendVerificationOTP({ + body: { email: identity.email, type: DELETION_CODE_TYPE }, + }); + return { status: 204, body: undefined }; + }, + delete: async ({ req, res, body }) => { + const boundary = dashboardSessionBoundary(req, res); + if (boundary) return boundary as any; + const identity = await currentUser(req); + if (!identity) return { status: 401, body: { error: "unauthorized" } }; + if (body.email.trim().toLowerCase() !== identity.email.toLowerCase()) { + return { status: 400, body: { error: "email_mismatch" } }; + } + try { + await auth.api.checkVerificationOTP({ + body: { email: identity.email, type: DELETION_CODE_TYPE, otp: body.code }, + }); + } catch { + return { status: 400, body: { error: "invalid_code" } }; + } + try { + await deleteAccount(identity.id); + } catch (error) { + if (error instanceof AccountDeletionBlockedError) { + return { + status: 409, + body: { + error: "account_deletion_blocked" as const, + ...serializePreview(error.preview), + }, + }; + } + throw error; + } + // The session row is gone; clear the browser's cookie too. + for (const name of [SESSION_COOKIE_NAME, `__Secure-${SESSION_COOKIE_NAME}`]) { + res.clearCookie(name, { + path: "/", + ...(process.env.AUTH_COOKIE_DOMAIN + ? { domain: process.env.AUTH_COOKIE_DOMAIN } + : {}), + }); + } + return { status: 204, body: undefined }; + }, +}); + +createExpressEndpoints(contract.account, impl, router); + +export default router; diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index b1d1c745..460dab12 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -10,6 +10,7 @@ import express from "express"; import rateLimit from "express-rate-limit"; import swaggerUi from "swagger-ui-express"; import { CLIENT_IP_HEADER } from "./abuse/signup"; +import accountRoutes from "./account/routes"; import { auth } from "./auth/better-auth"; import oauthPagesRoutes from "./auth/oauth-pages"; import billingRoutes from "./billing/routes"; @@ -149,6 +150,7 @@ export function createApp(options: CreateAppOptions) { app.use(billingRoutes); app.use(provisioningRoutes); app.use(organizationRoutes); + app.use(accountRoutes); // Team management is user-scoped: `/teams` must be registered before the // routers below, whose blanket `requireTeam` middleware otherwise intercepts diff --git a/apps/api/src/auth/better-auth.ts b/apps/api/src/auth/better-auth.ts index 89944e62..55475dc0 100644 --- a/apps/api/src/auth/better-auth.ts +++ b/apps/api/src/auth/better-auth.ts @@ -113,9 +113,24 @@ export const mcpProtectedResourceMetadataUrl = `${authBaseUrl}/.well-known/oauth const googleClientId = process.env.GOOGLE_CLIENT_ID; const googleClientSecret = process.env.GOOGLE_CLIENT_SECRET; -async function sendOtpEmail(email: string, otp: string) { +/** Codes of type `email-verification` only confirm deleting an account + * (`POST /account/deletion/code`); every other code signs in. */ +function otpEmailCopy(type: string) { + return type === "email-verification" + ? { + subject: "Confirm deleting your SendLit account", + intro: + "Enter this code to confirm deleting your SendLit account. If you didn't ask to delete it, ignore this email and sign in to check your account.", + } + : { + subject: "Your SendLit verification code", + intro: "Enter this code to sign in to SendLit:", + }; +} + +async function sendOtpEmail(email: string, otp: string, type = "sign-in") { if (process.env.NODE_ENV !== "production") { - logger.info({ email, otp }, "[Dev] Better Auth OTP generated"); + logger.info({ email, otp, type }, "[Dev] Better Auth OTP generated"); return; } @@ -137,12 +152,13 @@ async function sendOtpEmail(email: string, otp: string) { : undefined, }); + const copy = otpEmailCopy(type); await transporter.sendMail({ from: process.env.EMAIL_FROM, to: email, - subject: "Your SendLit verification code", - text: `Enter this code to sign in to SendLit: ${otp}`, - html: `

Enter this code to sign in to SendLit:

${otp}

`, + subject: copy.subject, + text: `${copy.intro} ${otp}`, + html: `

${copy.intro}

${otp}

`, }); } @@ -189,6 +205,11 @@ export const auth = betterAuth({ required: false, input: false, }, + deletedAt: { + type: "date", + required: false, + input: false, + }, }, }, session: { @@ -262,8 +283,8 @@ export const auth = betterAuth({ }, plugins: [ emailOTP({ - async sendVerificationOTP({ email, otp }) { - await sendOtpEmail(email, otp); + async sendVerificationOTP({ email, otp, type }) { + await sendOtpEmail(email, otp, type); }, storeOTP: "hashed", expiresIn: 5 * 60, diff --git a/apps/api/src/auth/resolve-auth.ts b/apps/api/src/auth/resolve-auth.ts index 46017821..15d40a69 100644 --- a/apps/api/src/auth/resolve-auth.ts +++ b/apps/api/src/auth/resolve-auth.ts @@ -237,7 +237,8 @@ export async function resolveAuth( const authenticated = await dependencies.authenticateBearer(token); if (!authenticated) return { status: "invalid_token" }; const user = await dependencies.getUser(authenticated.identity.subject); - if (!user) return { status: "unauthorized" }; + // A deleted account's unexpired tokens stop working at once (#134). + if (!user || user.deletedAt) return { status: "unauthorized" }; if (!(await dependencies.ensureDefaultOrganization(user.id))) { return { status: "unauthorized" }; } @@ -268,7 +269,7 @@ export async function resolveAuth( return { status: "unauthorized" }; } const user = await dependencies.getUser(session.identity.subject); - if (!user) return { status: "unauthorized" }; + if (!user || user.deletedAt) return { status: "unauthorized" }; if (!(await dependencies.ensureDefaultOrganization(user.id))) { return { status: "unauthorized" }; } diff --git a/apps/api/src/billing/security.ts b/apps/api/src/billing/security.ts index 197aaba7..235c7640 100644 --- a/apps/api/src/billing/security.ts +++ b/apps/api/src/billing/security.ts @@ -130,7 +130,12 @@ async function sessionContext(req: any) { return current; } -function commonBoundary(req: any, res: any): BillingSecurityFailure | null { +/** Session-only, same-origin and CSRF checks for sensitive dashboard writes + * (billing actions, account deletion). */ +export function dashboardSessionBoundary( + req: any, + res: any, +): BillingSecurityFailure | null { if (!req.userId || req.authKind !== "session") { return { status: 403, @@ -245,7 +250,7 @@ export async function issueBillingActionToken( action: BillingAction, target: string, ): Promise { - const boundary = commonBoundary(req, res); + const boundary = dashboardSessionBoundary(req, res); if (boundary) return boundary; if (!validTarget(target) || !billingActions.includes(action)) { return { status: 403, body: { error: "billing_action_invalid" } }; @@ -316,7 +321,7 @@ export async function readBillingActionToken( ): Promise< BillingSecurityFailure | { token: string; sessionId: string; sessionCreatedAt: Date } > { - const boundary = commonBoundary(req, res); + const boundary = dashboardSessionBoundary(req, res); if (boundary) return boundary; const token = typeof req.headers?.["x-sendlit-billing-action-token"] === "string" @@ -399,7 +404,7 @@ export async function requireBillingAction( action: LegacyAction, target: string, ): Promise { - const boundary = commonBoundary(req, res); + const boundary = dashboardSessionBoundary(req, res); if (boundary) return boundary; const token = typeof req.headers?.["x-sendlit-billing-action-token"] === "string" diff --git a/apps/api/src/db/schema-core.ts b/apps/api/src/db/schema-core.ts index e306c286..5d674bed 100644 --- a/apps/api/src/db/schema-core.ts +++ b/apps/api/src/db/schema-core.ts @@ -94,6 +94,10 @@ export const user = pgTable("user", { () => organizations.id, { onDelete: "set null" }, ), + /** Set when the person deleted their account (#134). The row stays as an + * anonymous tombstone so ids in billing, audit and trial records still + * resolve; its name and email are erased and it can never sign in. */ + deletedAt: timestamp("deleted_at", { withTimezone: true }), createdAt: timestamp("created_at", { withTimezone: true }).notNull(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(), }); diff --git a/apps/api/src/delivery-feedback/retention.ts b/apps/api/src/delivery-feedback/retention.ts index e75dcee5..1a135853 100644 --- a/apps/api/src/delivery-feedback/retention.ts +++ b/apps/api/src/delivery-feedback/retention.ts @@ -1,4 +1,4 @@ -import { and, eq, inArray, isNotNull, lt } from "drizzle-orm"; +import { type AnyColumn, and, eq, inArray, isNotNull, lt } from "drizzle-orm"; import { db } from "../db/client"; import { emailDeliveryEvents, @@ -92,6 +92,26 @@ export async function anonymizeRecipientForPrivacyDeletion({ teamId: string; normalizedRecipient: string; }): Promise { + await anonymizeRecipients(teamId, normalizedRecipient); +} + +/** + * The same erasure for every recipient of a team, used when the team's + * owner deletes their account (#134, ADR 0009). Suppression hashes stay. + */ +export async function anonymizeTeamRecipientsForPrivacyDeletion( + teamId: string, +): Promise { + await anonymizeRecipients(teamId, null); +} + +async function anonymizeRecipients( + teamId: string, + normalizedRecipient: string | null, +): Promise { + // `null` means every recipient of the team. + const recipient = (column: AnyColumn) => + normalizedRecipient === null ? undefined : eq(column, normalizedRecipient); await db.transaction(async (tx) => { await tx .update(outboundMessages) @@ -99,7 +119,7 @@ export async function anonymizeRecipientForPrivacyDeletion({ .where( and( eq(outboundMessages.teamId, teamId), - eq(outboundMessages.normalizedRecipient, normalizedRecipient), + recipient(outboundMessages.normalizedRecipient), ), ); await tx @@ -108,7 +128,7 @@ export async function anonymizeRecipientForPrivacyDeletion({ .where( and( eq(emailDeliveryEvents.teamId, teamId), - eq(emailDeliveryEvents.normalizedRecipient, normalizedRecipient), + recipient(emailDeliveryEvents.normalizedRecipient), ), ); await tx @@ -117,7 +137,7 @@ export async function anonymizeRecipientForPrivacyDeletion({ .where( and( eq(emailSuppressions.teamId, teamId), - eq(emailSuppressions.normalizedRecipient, normalizedRecipient), + recipient(emailSuppressions.normalizedRecipient), ), ); }); diff --git a/apps/api/src/observability/instance.ts b/apps/api/src/observability/instance.ts index a568ecb5..eda6c1e7 100644 --- a/apps/api/src/observability/instance.ts +++ b/apps/api/src/observability/instance.ts @@ -75,6 +75,10 @@ const PROPERTY_ALLOWLIST = new Set([ // Organization deletion (organization/delete.ts): counts only. "teams_count", "media_failed_count", + // Account deletion (account/delete.ts): counts only. + "organizations_deleted_count", + "organizations_left_count", + "contacts_erased_count", // Media cleanup on resource deletes (media/queries.ts deleteOrphanedMedia). "media_deleted_count", // Bulk contact deletion (contacts/bulk-delete.ts): counts only. diff --git a/apps/api/src/organization/queries.ts b/apps/api/src/organization/queries.ts index 4a5c762b..8792f269 100644 --- a/apps/api/src/organization/queries.ts +++ b/apps/api/src/organization/queries.ts @@ -370,15 +370,11 @@ export async function abandonPendingOrganization( }); } -export async function closeOrganization( +/** Why billing stops an organization from being closed right now, if it + * does: an active or paid-through subscription, or an open checkout. */ +export async function organizationCloseBlocker( organizationId: string, - actor: { - type: "user" | "organization_key" | "team_key" | "system"; - id?: string | null; - } = { - type: "system", - }, -): Promise { +): Promise<"active_subscription_exists" | "billing_checkout_pending" | null> { const { getBillingEngine } = await import("../billing/engine.js"); const blockers = await getBillingEngine().getBillableEntityBillingBlockers( { kind: "organization", id: organizationId }, @@ -388,11 +384,23 @@ export async function closeOrganization( blockers.includes("nonterminal_subscription") || blockers.includes("future_paid_entitlement") ) { - throw new Error("active_subscription_exists"); - } - if (blockers.includes("live_checkout")) { - throw new Error("billing_checkout_pending"); + return "active_subscription_exists"; } + if (blockers.includes("live_checkout")) return "billing_checkout_pending"; + return null; +} + +export async function closeOrganization( + organizationId: string, + actor: { + type: "user" | "organization_key" | "team_key" | "system"; + id?: string | null; + } = { + type: "system", + }, +): Promise { + const blocker = await organizationCloseBlocker(organizationId); + if (blocker) throw new Error(blocker); await db.transaction(async (tx) => { // Re-check on locked rows so a projection cannot slip in between the // blocker check above and the close. @@ -562,12 +570,15 @@ async function assertNotLastOwner( } } -async function assertBillingManagerRetained( - tx: Parameters[0]>[0], +/** Whether the user pays for a subscription of the organization that is + * still running, so they can't leave it until billing moves or ends. */ +export async function isActiveBillingManager( + tx: Pick, organizationId: string, userId: string, -): Promise { - const subscriptions = await tx + lock = false, +): Promise { + const query = tx .select({ id: billingSubscriptions.id, status: billingSubscriptions.status, @@ -581,9 +592,17 @@ async function assertBillingManagerRetained( eq(billingSubscriptions.payerId, userId), ), ) - .orderBy(asc(billingSubscriptions.id)) - .for("update"); - if (subscriptions.some(blocksBillingChange)) { + .orderBy(asc(billingSubscriptions.id)); + const subscriptions = lock ? await query.for("update") : await query; + return subscriptions.some(blocksBillingChange); +} + +async function assertBillingManagerRetained( + tx: Parameters[0]>[0], + organizationId: string, + userId: string, +): Promise { + if (await isActiveBillingManager(tx, organizationId, userId, true)) { throw new Error("billing_manager_required"); } } @@ -691,7 +710,8 @@ export async function ensureDefaultOrganization( .where(eq(user.id, userId)) .limit(1) .for("update"); - if (!identity) return null; + // A deleted account (#134) never gets a new organization. + if (!identity || identity.deletedAt) return null; if (identity.defaultOrganizationId) { const [existing] = await tx diff --git a/packages/api-contract/src/contract.ts b/packages/api-contract/src/contract.ts index da7fc5f1..c358cf34 100644 --- a/packages/api-contract/src/contract.ts +++ b/packages/api-contract/src/contract.ts @@ -1,5 +1,10 @@ import { initContract } from "@ts-rest/core"; import { z } from "zod"; +import { + accountDeletionBlockedSchema, + accountDeletionPreviewSchema, + deleteAccountBodySchema, +} from "./schemas/account"; import { billingActionTokenBodySchema, billingActionTokenResponseSchema, @@ -2258,7 +2263,52 @@ const billingContract = c.router( { metadata: { tag: "Billing" } }, ); +const accountContract = c.router({ + getDeletion: { + method: "GET", + path: "/account/deletion", + responses: { + 200: accountDeletionPreviewSchema, + 401: errorSchema, + 403: errorSchema, + }, + summary: "Preview deleting your account", + description: + "Dashboard session only. Lists every organization you belong to and what deleting your account does to it: `delete` when you are its last owner (it is deleted and its contacts' personal data erased), `leave` otherwise. `blocker` says what has to happen first: `active_subscription_exists` or `billing_checkout_pending` (cancel billing), `last_organization_owner` (other people are members: make one of them an owner or remove them), `billing_manager_required` (you pay for a running subscription of an organization you would leave).", + }, + sendDeletionCode: { + method: "POST", + path: "/account/deletion/code", + body: c.noBody(), + responses: { + 204: c.noBody(), + 401: errorSchema, + 403: errorSchema, + 429: errorSchema, + }, + summary: "Email a code to confirm deleting your account", + description: + "Dashboard session only. Sends a one-time code, valid for 5 minutes, to the account's email. `DELETE /account` needs it.", + }, + delete: { + method: "DELETE", + path: "/account", + body: deleteAccountBodySchema, + responses: { + 204: c.noBody(), + 400: errorSchema, + 401: errorSchema, + 403: errorSchema, + 409: accountDeletionBlockedSchema, + }, + summary: "Delete your account", + description: + "Dashboard session only, with the account's email typed to confirm and the code from `POST /account/deletion/code` (`400 email_mismatch`, `400 invalid_code`). Fails with `409 account_deletion_blocked`, listing the blocking organizations, while any blocker from `GET /account/deletion` remains; nothing changes then. Otherwise every organization you are the last owner of is deleted like `DELETE /organizations/:organizationId` and its contacts, signup confirmations, recipient addresses and raw webhook payloads are erased (suppression hashes stay); you leave every other organization and team; your sessions, sign-in methods, OAuth tokens and consents end; and your name and email are erased. Billing records and audit events keep an anonymous id. Signing in again with the same email creates a new, empty account.", + }, +}); + export const contract = c.router({ + account: accountContract, contacts: contactsContract, segments: segmentsContract, tags: tagsContract, diff --git a/packages/api-contract/src/index.ts b/packages/api-contract/src/index.ts index f99f3151..93561198 100644 --- a/packages/api-contract/src/index.ts +++ b/packages/api-contract/src/index.ts @@ -6,6 +6,7 @@ export { type McpParityRisk, mcpParityManifest, } from "./mcp-parity"; +export * from "./schemas/account"; export * from "./schemas/billing"; export * from "./schemas/common"; export * from "./schemas/contacts"; diff --git a/packages/api-contract/src/mcp-parity.ts b/packages/api-contract/src/mcp-parity.ts index 587544f2..4b591637 100644 --- a/packages/api-contract/src/mcp-parity.ts +++ b/packages/api-contract/src/mcp-parity.ts @@ -73,6 +73,8 @@ function mcpOnly(tool: string, reason: string): McpParityManifestEntry { const BILLING_SESSION_ONLY = "Billing is dashboard-session only: plan changes, checkout, portal, cancellation and action tokens require a human session with recent authentication, so there are no MCP tools."; +const ACCOUNT_DELETION = + "Deleting an account is dashboard-session only and needs a code emailed to the person; MCP and API keys must never be able to erase a person's account."; const ORGANIZATION_ADMIN = "Organization administration (members, teams, shared ESPs, feedback, delivery policy, sending domains, usage and audit) is organization-scoped; MCP is team-scoped and accepts neither organization sessions nor organization API keys."; const ORGANIZATION_KEYS = @@ -227,6 +229,9 @@ export const mcpParityManifest: readonly McpParityManifestEntry[] = [ required("teams.removeKey", "delete_api_key", "destructive"), // Organization administration + restOnly("account.getDeletion", ACCOUNT_DELETION), + restOnly("account.sendDeletionCode", ACCOUNT_DELETION), + restOnly("account.delete", ACCOUNT_DELETION), restOnly("organizations.list", ORGANIZATION_ADMIN), restOnly("organizations.create", ORGANIZATION_ADMIN), restOnly("organizations.get", ORGANIZATION_ADMIN), diff --git a/packages/api-contract/src/openapi.ts b/packages/api-contract/src/openapi.ts index d4675f80..17d73fe1 100644 --- a/packages/api-contract/src/openapi.ts +++ b/packages/api-contract/src/openapi.ts @@ -61,6 +61,7 @@ function getAuthMode(path: string, method: string): AuthMode { if (dashboardSessionOnlyOperations.has(`${verb} ${path}`)) { return "dashboardSession"; } + if (path === "/account" || path.startsWith("/account/")) return "dashboardSession"; if (path === "/billing/catalog") return "public"; if (path.startsWith("/provisioning/")) return "organization"; if (path.startsWith("/teams")) return "user"; diff --git a/packages/api-contract/src/schemas/account.ts b/packages/api-contract/src/schemas/account.ts new file mode 100644 index 00000000..6ea86b4a --- /dev/null +++ b/packages/api-contract/src/schemas/account.ts @@ -0,0 +1,40 @@ +import { z } from "zod"; +import { organizationRoleSchema } from "./organizations"; + +export const accountDeletionBlockerSchema = z.enum([ + "active_subscription_exists", + "billing_checkout_pending", + "last_organization_owner", + "billing_manager_required", +]); + +/** What deleting the account does to one organization the person is in. */ +export const accountDeletionOrganizationSchema = z.object({ + organizationId: z.string(), + name: z.string(), + status: z.enum(["pending_payment", "active", "suspended", "abandoned", "closed"]), + role: organizationRoleSchema, + /** `delete`: they are its last owner, so it is deleted with its contacts' + * data. `leave`: someone else stays in charge; only their membership goes. */ + action: z.enum(["delete", "leave"]), + teamCount: z.number().int(), + /** What has to happen before the account can be deleted, if anything. */ + blocker: accountDeletionBlockerSchema.nullable(), +}); + +export const accountDeletionPreviewSchema = z.object({ + email: z.string(), + organizations: z.array(accountDeletionOrganizationSchema), + blocked: z.boolean(), +}); + +export const deleteAccountBodySchema = z.object({ + /** The account's email, typed to confirm. */ + email: z.string().trim().min(1), + /** The code sent by `POST /account/deletion/code`. */ + code: z.string().trim().min(1), +}); + +export const accountDeletionBlockedSchema = accountDeletionPreviewSchema + .omit({ email: true }) + .extend({ error: z.literal("account_deletion_blocked") }); From 748c66b7409bce705d353b4791a9dbbd21a1b3a9 Mon Sep 17 00:00:00 2001 From: Rajat Saxena Date: Thu, 29 Oct 2026 17:47:34 +0530 Subject: [PATCH 2/5] docs: record account deletion erasure semantics in ADR 0009 (#134) --- ...on-erases-the-person-and-their-contacts.md | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 docs/decisions/0009-account-deletion-erases-the-person-and-their-contacts.md diff --git a/docs/decisions/0009-account-deletion-erases-the-person-and-their-contacts.md b/docs/decisions/0009-account-deletion-erases-the-person-and-their-contacts.md new file mode 100644 index 00000000..f5dd34f9 --- /dev/null +++ b/docs/decisions/0009-account-deletion-erases-the-person-and-their-contacts.md @@ -0,0 +1,96 @@ +# ADR 0009: Account deletion erases the person and their contacts' data + +Status: accepted +Date: 2026-10-29 + +## Context + +GDPR's right to erasure (Art. 17) means a person must be able to delete +their SendLit account and the data that comes with it (#134). An account +owns organizations, and an organization holds the personal data of its +contacts. For that data the customer is the controller and SendLit the +processor, and a processor deletes the controller's personal data when the +service ends (Art. 28(3)(g)). + +Deleting an organization (#151) only closes it: billing, audit, delivery +reporting and suppressions stay, and so do its contacts. That is right +while the owner's account exists, because they may come back, but not when +the owner is gone and nobody can reach the data any more. + +Better Auth has a `deleteUser` endpoint, but it hard-deletes the `user` row. +Billing subscriptions, trial claims (`trial_claims.user_id` is `RESTRICT`) +and audit events refer to the user id, and some of them have to be kept +(see below). GitHub, Stripe and Mailchimp all ask you to re-authenticate, +list what will happen, and refuse while you still own a paid or shared +workspace. + +## Decision + +- **Where and how.** Account → Danger zone. The dialog lists every + organization and what happens to it, then asks the person to type their + email and enter a code emailed to them (Better Auth's email OTP, + `email-verification` type, reserved for this). The API + (`GET /account/deletion`, `POST /account/deletion/code`, `DELETE /account`) + accepts dashboard sessions only, with the same origin and CSRF checks as + billing actions. MCP and API keys can't delete an account. +- **Blockers.** Nothing changes while any of these hold, and the dialog + says what to do about each: + - an organization they are the last owner of has a running or + paid-through subscription, or an open checkout: cancel it first. We + never cancel billing on the person's behalf; + - they are the last owner and other people are members: make someone + else an owner, or remove them; + - they pay for a running subscription of an organization they would + leave. +- **Organizations they are the last owner of** are deleted with + `deleteOrganization` (keys, domains, ESPs, MediaLit files, ADR 0006), + and then the personal data the organization holds about its contacts is + erased (`eraseOrganizationPersonalData`): contacts with everything that + cascades from them (custom field values, events, enrolments, + per-contact delivery rows), signup confirmations, recipient addresses + and rendered bodies of transactional email, recipient addresses on + outbound messages, delivery events and suppressions, raw ESP webhook + payloads, last-seen event properties and reputation connections. An + organization deleted earlier, of which they are still the last owner, is + erased the same way. +- **Suppression hashes stay.** The versioned HMAC on each suppression row is + not personal data on its own and keeps an address blocked if the same + list is imported again into a reused team. The `anonymizeRecipient…` + helpers in `delivery-feedback/retention.ts` already do exactly this. +- **Shared organizations** keep everything; the person only leaves them and + their teams. An audit event `organization.member_account_deleted` records + the opaque user id. Team API keys they created belong to the team and keep + working. +- **The person.** Sessions, sign-in methods (`account`), OAuth tokens, + consents and clients they registered, and pending codes are deleted, so + they are signed out everywhere. The `user` row stays as a tombstone: name + "Deleted user", email `deleted-@deleted.invalid`, `deleted_at` set. + `resolve-auth` and `ensureDefaultOrganization` refuse a deleted user, so + an unexpired OAuth access token stops working at once. Abuse flags drop + the email and IP from their details. +- **Kept, with an opaque id only:** billing records (tax and accounting + law), trial claims (their keyed email fingerprint stops the same email + getting a second free trial, a legitimate interest), organization audit + events, aggregate reporting and the closed organization itself. +- **Signing up again** with the same email creates a new, empty account + and organization. +- **No later purge job.** Erasure runs in the request, in bounded batches + for contacts. The closed organization's remaining rows hold no personal + data, so there is nothing for a background job to finish. MediaLit + failures already retry on the media deletion queue. +- **Telemetry.** `account_deleted` with the user id and counts only. Our + observability package has no PostHog person deletion; the browser resets + its PostHog identity after the delete. Deleting the PostHog person is a + follow-up for when `@codelitdev/observability` supports it. + +## Consequences + +- An account delete can be slow for very large lists, because contacts are + deleted in the request. If that becomes a problem, the erasure can move + to a queue without changing the API. +- If billing changes between the preview and the delete, organizations + deleted before the blocking one stay deleted and the request returns + `409 account_deletion_blocked`; retrying after resolving it finishes the + job. +- `user.deleted_at` is nullable and additive, so the previous release keeps + working against the migrated schema. From 410e5265c7428b9c609dcdcedbd698d69eb1358f Mon Sep 17 00:00:00 2001 From: Rajat Saxena Date: Thu, 29 Oct 2026 17:49:59 +0530 Subject: [PATCH 3/5] =?UTF-8?q?feat(web):=20delete=20your=20account=20from?= =?UTF-8?q?=20Account=20=E2=86=92=20Danger=20zone=20with=20emailed-code=20?= =?UTF-8?q?confirmation=20(#134)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/app/(dashboard)/account/page.tsx | 39 +- .../dashboard/delete-account-dialog.test.tsx | 124 +++++ .../dashboard/delete-account-dialog.tsx | 431 ++++++++++++++++++ apps/web/lib/api.ts | 25 + packages/api-contract/src/schemas/account.ts | 5 + 5 files changed, 623 insertions(+), 1 deletion(-) create mode 100644 apps/web/components/dashboard/delete-account-dialog.test.tsx create mode 100644 apps/web/components/dashboard/delete-account-dialog.tsx diff --git a/apps/web/app/(dashboard)/account/page.tsx b/apps/web/app/(dashboard)/account/page.tsx index 92e4790d..ae973104 100644 --- a/apps/web/app/(dashboard)/account/page.tsx +++ b/apps/web/app/(dashboard)/account/page.tsx @@ -1,10 +1,11 @@ "use client"; import { Loader } from "@codelitdev/design-system"; -import { UserRound } from "lucide-react"; +import { Trash2, UserRound } from "lucide-react"; import { useRouter, useSearchParams } from "next/navigation"; import { useEffect, useState } from "react"; import { useSetBreadcrumb } from "@/components/dashboard/breadcrumb-context"; +import { DeleteAccountDialog } from "@/components/dashboard/delete-account-dialog"; import { Loading } from "@/components/dashboard/loading"; import { PageHeader } from "@/components/dashboard/page-header"; import { ScrollablePage } from "@/components/dashboard/scrollable-page"; @@ -19,6 +20,9 @@ import { Button } from "@/components/ui/codelit/button"; import { Input } from "@/components/ui/codelit/input"; import { Label } from "@/components/ui/codelit/label"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/codelit/tabs"; +import { navigateTo } from "@/lib/navigation"; +import { resetPosthogUser } from "@/lib/posthog-browser"; +import { clearOrganizationIdCookie, clearTeamIdCookie } from "@/lib/tokens"; const ACCOUNT_TABS = ["general"] as const; type AccountTab = (typeof ACCOUNT_TABS)[number]; @@ -43,6 +47,7 @@ export default function AccountPage() { const [isEditingProfile, setIsEditingProfile] = useState(false); const [isSavingProfile, setIsSavingProfile] = useState(false); const [profileError, setProfileError] = useState(null); + const [deleteOpen, setDeleteOpen] = useState(false); useEffect(() => { if (tabParam !== "billing" && tabParam !== "notifications") return; @@ -211,6 +216,38 @@ export default function AccountPage() { + + + Danger zone + + Deleting your account can’t be undone. + + + +

+ Delete your account and erase your name and email. Organizations only + you own are deleted with their contacts; you leave the others. +

+ +
+
+ { + resetPosthogUser(); + clearTeamIdCookie(); + clearOrganizationIdCookie(); + navigateTo("/login"); + }} + /> diff --git a/apps/web/components/dashboard/delete-account-dialog.test.tsx b/apps/web/components/dashboard/delete-account-dialog.test.tsx new file mode 100644 index 00000000..5418dc03 --- /dev/null +++ b/apps/web/components/dashboard/delete-account-dialog.test.tsx @@ -0,0 +1,124 @@ +import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; +import { registerDom } from "@sendlit/test-utils/dom"; + +registerDom(); + +type Preview = { + email: string; + blocked: boolean; + organizations: Array<{ + organizationId: string; + name: string; + status: "active" | "closed"; + role: "owner" | "admin" | "member"; + action: "delete" | "leave"; + teamCount: number; + blocker: null | "active_subscription_exists" | "last_organization_owner"; + }>; +}; + +let preview: Preview; +const getAccountDeletion = mock(async () => preview); +const sendAccountDeletionCode = mock(async () => undefined); +const deleteAccount = mock(async (_email: string, _code: string) => undefined); + +mock.module("@/lib/api", () => ({ + getAccountDeletion, + sendAccountDeletionCode, + deleteAccount, +})); + +const { act, cleanup, fireEvent, render, screen } = await import( + "@testing-library/react" +); +const { DeleteAccountDialog } = await import("./delete-account-dialog"); + +async function settle() { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +function deleteButton() { + return screen.getByRole("button", { name: "Delete account" }) as HTMLButtonElement; +} + +beforeEach(() => { + preview = { + email: "ada@example.com", + blocked: false, + organizations: [ + { + organizationId: "org_solo", + name: "Solo", + status: "active", + role: "owner", + action: "delete", + teamCount: 2, + blocker: null, + }, + { + organizationId: "org_shared", + name: "Shared", + status: "active", + role: "owner", + action: "leave", + teamCount: 1, + blocker: null, + }, + ], + }; + getAccountDeletion.mockClear(); + sendAccountDeletionCode.mockClear(); + deleteAccount.mockClear(); +}); +afterEach(cleanup); + +describe("DeleteAccountDialog", () => { + it("lists each organization's fate and deletes after the email and code", async () => { + const onDeleted = mock(); + render(); + await settle(); + + expect(screen.getByText("2 of 2 organizations ready")).toBeTruthy(); + expect( + screen.getByText("Deleted with its 2 teams, contacts and media."), + ).toBeTruthy(); + expect(screen.getByText("You'll leave")).toBeTruthy(); + expect(deleteButton().disabled).toBe(true); + + fireEvent.click(screen.getByRole("button", { name: "Send code" })); + await settle(); + expect(sendAccountDeletionCode).toHaveBeenCalled(); + + fireEvent.change(screen.getByLabelText(/to confirm/), { + target: { value: "ada@example.com" }, + }); + fireEvent.change(screen.getByLabelText("Code from your email"), { + target: { value: "123456" }, + }); + expect(deleteButton().disabled).toBe(false); + fireEvent.click(deleteButton()); + await settle(); + + expect(deleteAccount).toHaveBeenCalledWith("ada@example.com", "123456"); + expect(onDeleted).toHaveBeenCalled(); + }); + + it("opens blocked organizations and keeps the delete disabled", async () => { + preview.blocked = true; + preview.organizations[0].blocker = "active_subscription_exists"; + render(); + await settle(); + + expect(screen.getByText("1 of 2 organizations ready")).toBeTruthy(); + expect(screen.getByText("Solo needs attention")).toBeTruthy(); + expect(screen.getByText("Cancel billing first")).toBeTruthy(); + expect(screen.getByText(/Cancel this organization's subscription/)).toBeTruthy(); + expect(screen.getByRole("link", { name: /Manage billing/ })).toBeTruthy(); + expect( + (screen.getByRole("button", { name: "Send code" }) as HTMLButtonElement).disabled, + ).toBe(true); + expect(deleteButton().disabled).toBe(true); + }); +}); diff --git a/apps/web/components/dashboard/delete-account-dialog.tsx b/apps/web/components/dashboard/delete-account-dialog.tsx new file mode 100644 index 00000000..765593d5 --- /dev/null +++ b/apps/web/components/dashboard/delete-account-dialog.tsx @@ -0,0 +1,431 @@ +"use client"; + +import { ArrowRight, Check, ChevronDown, Mail, Trash2 } from "lucide-react"; +import Link from "next/link"; +import { useCallback, useEffect, useId, useState } from "react"; +import { Banner } from "@/components/dashboard/banner"; +import { Loading } from "@/components/dashboard/loading"; +import { Badge } from "@/components/ui/codelit/badge"; +import { Button } from "@/components/ui/codelit/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/codelit/dialog"; +import { Input } from "@/components/ui/codelit/input"; +import { Label } from "@/components/ui/codelit/label"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { + type AccountDeletionOrganization, + type AccountDeletionPreview, + deleteAccount, + getAccountDeletion, + sendAccountDeletionCode, +} from "@/lib/api"; +import { ApiError } from "@/lib/api-client"; +import { cn } from "@/lib/utils"; + +type Status = "ready" | "attention"; + +const STATUS_STYLE: Record< + Status, + { icon: string; bar: string; badge: "success" | "warning" } +> = { + ready: { + icon: "bg-[var(--success-soft)] text-[#1f6b35]", + bar: "bg-[var(--success)]", + badge: "success", + }, + attention: { + icon: "bg-[var(--warning-soft)] text-[var(--warning)]", + bar: "bg-[var(--warning)]", + badge: "warning", + }, +}; + +const BLOCKER_LABEL: Record< + NonNullable, + string +> = { + active_subscription_exists: "Cancel billing first", + billing_checkout_pending: "Checkout in progress", + last_organization_owner: "Add another owner first", + billing_manager_required: "Move billing first", +}; + +function plural(count: number, word: string) { + return `${count} ${word}${count === 1 ? "" : "s"}`; +} + +function summaryOf(organization: AccountDeletionOrganization): string { + if (organization.action === "leave") { + return "You leave it. It stays with its other owners."; + } + if (organization.status === "closed") { + return "Already deleted. Its contacts' data is erased."; + } + return `Deleted with its ${plural(organization.teamCount, "team")}, contacts and media.`; +} + +function instructionOf(organization: AccountDeletionOrganization): string { + switch (organization.blocker) { + case "active_subscription_exists": + return "Cancel this organization's subscription, then delete your account once the paid period ends."; + case "billing_checkout_pending": + return "A checkout for this organization is still open. Finish or abandon it, then try again."; + case "last_organization_owner": + return "You're its only owner and other people are members. Make one of them an owner, or remove them, so they don't lose their workspace."; + case "billing_manager_required": + return "You pay for this organization's subscription. Ask an owner to cancel it or take over billing first."; + default: + return organization.action === "leave" + ? "Nothing to do. Your membership and team access are removed; its contacts, campaigns and billing stay with the organization." + : "Nothing to do. Its teams stop sending, its keys, sending domains and mailboxes are retired, its media is deleted, and its contacts and their personal data are erased. Suppressed addresses stay blocked. Billing records are kept for accounting, without your name or email."; + } +} + +/** + * Account deletion (#134): one section per organization saying what happens + * to it and what has to happen first, then a step that confirms it's really + * the account's owner (typed email plus an emailed code). + */ +export function DeleteAccountDialog({ + open, + onOpenChange, + onDeleted, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + onDeleted: () => void; +}) { + const [preview, setPreview] = useState(null); + const [loadError, setLoadError] = useState(null); + const [expanded, setExpanded] = useState>({}); + const [typedEmail, setTypedEmail] = useState(""); + const [code, setCode] = useState(""); + const [codeSentAt, setCodeSentAt] = useState(null); + const [sending, setSending] = useState(false); + const [deleting, setDeleting] = useState(false); + const [error, setError] = useState(null); + const emailId = useId(); + const codeId = useId(); + + const load = useCallback(async () => { + setLoadError(null); + try { + const next = await getAccountDeletion(); + setPreview(next); + // Open what needs attention; fold the rest. + setExpanded( + Object.fromEntries( + next.organizations.map((organization) => [ + organization.organizationId, + organization.blocker !== null, + ]), + ), + ); + } catch { + setLoadError("Unable to check your organizations. Please try again."); + } + }, []); + + useEffect(() => { + if (!open) return; + setPreview(null); + setTypedEmail(""); + setCode(""); + setCodeSentAt(null); + setError(null); + void load(); + }, [open, load]); + + const organizations = preview?.organizations ?? []; + const blocked = organizations.filter((organization) => organization.blocker); + const ready = organizations.length - blocked.length; + const emailMatches = + preview !== null && typedEmail.trim().toLowerCase() === preview.email.toLowerCase(); + const canDelete = + preview !== null && !preview.blocked && emailMatches && code.trim().length > 0; + + async function sendCode() { + setSending(true); + setError(null); + try { + await sendAccountDeletionCode(); + setCodeSentAt(new Date()); + } catch (failure) { + setError( + failure instanceof ApiError && failure.status === 429 + ? "Too many codes requested. Wait a minute and try again." + : "Unable to send the code. Please try again.", + ); + } finally { + setSending(false); + } + } + + async function confirmDelete() { + if (!preview) return; + setDeleting(true); + setError(null); + try { + await deleteAccount(typedEmail.trim(), code.trim()); + onDeleted(); + } catch (failure) { + const reason = failure instanceof ApiError ? failure.message : null; + if (reason === "account_deletion_blocked") { + setError( + "Something changed in one of your organizations. Check the list above.", + ); + await load(); + } else if (reason === "invalid_code") { + setError("That code is wrong or has expired. Send a new one and try again."); + } else if (reason === "email_mismatch") { + setError("Type your account's email exactly as shown."); + } else { + setError("Unable to delete your account. Please try again."); + } + setDeleting(false); + } + } + + return ( + !deleting && onOpenChange(next)}> + + + + Delete{" "} + {preview ? ( + {preview.email} + ) : ( + "your account" + )} + + + Your name and email are erased and you're signed out everywhere. + Organizations only you own are deleted with their contacts. This can't be + undone. + + {preview && organizations.length > 0 && ( +
+
+ {organizations.map((organization) => ( + + ))} +
+ + {ready} of {plural(organizations.length, "organization")} ready + + + {blocked.length === 0 + ? "Nothing is in the way" + : `${blocked.map((organization) => organization.name).join(", ")} ${blocked.length === 1 ? "needs" : "need"} attention`} + +
+ )} +
+ +
+ {loadError && {loadError}} + {!preview && !loadError && } + {preview && + organizations.map((organization) => ( + + setExpanded((current) => ({ + ...current, + [organization.organizationId]: value, + })) + } + onNavigate={() => onOpenChange(false)} + /> + ))} + {preview && ( +
+
+ + {emailMatches && code ? : "!"} + + + Confirm it's you + + Type your email and enter the code we send to it. + + + + {emailMatches && code ? "Confirmed" : "Needs confirmation"} + +
+
+
+ + setTypedEmail(event.target.value)} + /> +
+
+ +
+ setCode(event.target.value)} + /> + +
+ {codeSentAt && ( +

+ We sent a code to{" "} + {preview.email}. +

+ )} +
+
+
+ )} + {error && ( +

+ {error} +

+ )} +
+ + +

Codes expire after 5 minutes.

+
+ + +
+
+
+
+ ); +} + +function OrganizationSection({ + organization, + open, + onOpenChange, + onNavigate, +}: { + organization: AccountDeletionOrganization; + open: boolean; + onOpenChange: (open: boolean) => void; + onNavigate: () => void; +}) { + const status: Status = organization.blocker ? "attention" : "ready"; + const badge = organization.blocker + ? BLOCKER_LABEL[organization.blocker] + : organization.action === "leave" + ? "You'll leave" + : "Will be deleted"; + const billingLink = + organization.blocker === "active_subscription_exists" || + organization.blocker === "billing_checkout_pending" || + organization.blocker === "billing_manager_required"; + return ( + + + + {status === "ready" ? : "!"} + + + {organization.name} + + {summaryOf(organization)} + + + {badge} + + + +

{instructionOf(organization)}

+ {organization.blocker && ( + + {billingLink ? "Manage billing" : "Manage members"} + + + )} +

+ {organization.organizationId} +

+
+
+ ); +} diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts index 7918769e..7ea1a6e6 100644 --- a/apps/web/lib/api.ts +++ b/apps/web/lib/api.ts @@ -1,4 +1,6 @@ import { + type AccountDeletionOrganization, + type AccountDeletionPreview, type BillingCatalogKey, type BillingPlan, type ContactDeletion, @@ -1205,6 +1207,29 @@ export function deleteOrganization(organizationId: string) { ); } +// ---- Account -------------------------------------------------------------- + +export type { AccountDeletionOrganization, AccountDeletionPreview }; + +/** What deleting the account does to each organization, and what blocks it. */ +export function getAccountDeletion() { + return organizationRequest("/account/deletion"); +} + +/** Emails the code `deleteAccount` needs. */ +export function sendAccountDeletionCode() { + return organizationRequest("/account/deletion/code", { method: "POST" }); +} + +/** Deletes the signed-in account (ADR 0009). Fails with `email_mismatch`, + * `invalid_code` or `account_deletion_blocked`. */ +export function deleteAccount(email: string, code: string) { + return organizationRequest("/account", { + method: "DELETE", + body: JSON.stringify({ email, code }), + }); +} + export function createOrganization(name: string) { return organizationRequest("/organizations", { method: "POST", diff --git a/packages/api-contract/src/schemas/account.ts b/packages/api-contract/src/schemas/account.ts index 6ea86b4a..67654ee2 100644 --- a/packages/api-contract/src/schemas/account.ts +++ b/packages/api-contract/src/schemas/account.ts @@ -38,3 +38,8 @@ export const deleteAccountBodySchema = z.object({ export const accountDeletionBlockedSchema = accountDeletionPreviewSchema .omit({ email: true }) .extend({ error: z.literal("account_deletion_blocked") }); + +export type AccountDeletionPreview = z.infer; +export type AccountDeletionOrganization = z.infer< + typeof accountDeletionOrganizationSchema +>; From d94853220ed9368cce7c94b05baf6e3ae936056c Mon Sep 17 00:00:00 2001 From: Rajat Saxena Date: Thu, 29 Oct 2026 17:50:15 +0530 Subject: [PATCH 4/5] docs: explain deleting your account (#134) --- apps/docs/content/docs/developers/errors.mdx | 11 ++++++ apps/docs/content/docs/developers/mcp.mdx | 2 ++ apps/docs/content/docs/workspace/account.mdx | 37 +++++++++++++++++++- 3 files changed, 49 insertions(+), 1 deletion(-) diff --git a/apps/docs/content/docs/developers/errors.mdx b/apps/docs/content/docs/developers/errors.mdx index dba569b8..5a979d29 100644 --- a/apps/docs/content/docs/developers/errors.mdx +++ b/apps/docs/content/docs/developers/errors.mdx @@ -55,6 +55,17 @@ template or transactional delivery. See [Custom events](/developers/events) and [Sequences](/email-marketing/sequences#custom-events). +## Account deletion errors + +`DELETE /account` (dashboard session only): + +| Status | Error | Meaning | +| ------ | -------------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| `403` | `human_session_required` | Account deletion needs a dashboard session. OAuth tokens and API keys can't delete an account. | +| `400` | `email_mismatch` | The typed email isn't the account's email. | +| `400` | `invalid_code` | The code from `POST /account/deletion/code` is wrong or expired. Request a new one. | +| `409` | `account_deletion_blocked` | Something has to happen first. The body lists `organizations` with their `blocker`: `active_subscription_exists`, `billing_checkout_pending`, `last_organization_owner` or `billing_manager_required`. | + ## Authentication errors | Status | Error | Meaning | diff --git a/apps/docs/content/docs/developers/mcp.mdx b/apps/docs/content/docs/developers/mcp.mdx index f430fd9a..f04818d3 100644 --- a/apps/docs/content/docs/developers/mcp.mdx +++ b/apps/docs/content/docs/developers/mcp.mdx @@ -51,6 +51,8 @@ Organization API keys, dashboard session cookies, and `X-Sendlit-Team-Id` are not accepted as MCP authentication. Use the REST API for organization discovery, shared-delivery setup, and team provisioning; see [Headless organization setup](/developers/headless-provisioning). +Deleting an account is dashboard-only and has no MCP tool; see +[Account](/workspace/account#delete-your-account). OAuth clients (including MCP clients like Claude) have no way to send a custom header during authorization, so a multi-team account instead picks a team as part of the OAuth flow itself: after login, and before the consent screen, the account is shown a "select a team" step (mirroring how apps like Notion resolve their own multi-workspace ambiguity). The chosen team is baked into the issued access token and used for every subsequent request — no header needed. Accounts with exactly one team skip this step entirely. diff --git a/apps/docs/content/docs/workspace/account.mdx b/apps/docs/content/docs/workspace/account.mdx index e08b61dd..c37e8dc6 100644 --- a/apps/docs/content/docs/workspace/account.mdx +++ b/apps/docs/content/docs/workspace/account.mdx @@ -1,6 +1,6 @@ --- title: Account -description: Understand account access and organization-scoped billing. +description: Understand account access, organization-scoped billing, and deleting your account. --- The dashboard home shows the current team’s delivery activity and allowance: sent, queued, failed, bounced, active sequences, scheduled broadcasts, and daily/monthly quota usage. @@ -14,3 +14,38 @@ for the complete plan rules. Quotas shown on the dashboard are team-scoped, so switching teams changes the numbers shown there. Organization billing usage is shown on the organization's **Plan** tab. + +## Delete your account + +Open **Account** and choose **Delete account** in the danger zone. The +dialog lists every organization you belong to and what happens to it: + +- **Will be deleted**: you are its only owner. It is deleted like + [deleting an organization](/workspace/organizations), and the personal + data it holds about its contacts is erased: contacts and their fields, + events and enrolments, signup confirmations, recipient addresses in + delivery history and transactional email, and raw ESP webhook payloads. + Suppressed addresses stay blocked through an anonymous hash. +- **You'll leave**: another owner stays in charge. The organization keeps + its contacts, campaigns, keys and billing; you only lose access. + +Some things have to happen first, and the dialog links to where you fix +them: + +- an organization you own alone has an active or paid-through + subscription, or an open checkout: cancel it first; +- you own an organization alone and other people are members: make one of + them an owner, or remove them; +- you pay for the subscription of an organization you would leave: ask an + owner to cancel it or take over billing. + +To confirm, type your email and enter the code SendLit emails you. Codes +expire after 5 minutes. When the account is deleted your name and email are +erased and you are signed out everywhere, including OAuth and MCP clients. +Billing records and audit events are kept for accounting and security, with +an anonymous id instead of your name or email. Signing up again with the +same email creates a new, empty account. + +The REST API offers the same flow from a dashboard session only: +`GET /account/deletion`, `POST /account/deletion/code` and `DELETE /account`. +It isn't available over MCP or with API keys. From 60f451bfe6483bbd2beb48264b3b0765eaca2d6e Mon Sep 17 00:00:00 2001 From: Rajat Saxena Date: Thu, 29 Oct 2026 18:11:43 +0530 Subject: [PATCH 5/5] fix: confirm account deletion with POST /account/deletion so the dashboard proxy forwards the body (#134) The web proxy drops DELETE bodies, so the typed email and code never reached the API. Also rate-limit deletion codes per person rather than per IP, which the proxy shares. --- apps/api/src/account/delete.routes.test.ts | 14 ++++++++------ apps/api/src/account/routes.ts | 2 ++ apps/docs/content/docs/developers/errors.mdx | 2 +- apps/docs/content/docs/workspace/account.mdx | 2 +- apps/web/lib/api.ts | 4 ++-- ...eletion-erases-the-person-and-their-contacts.md | 2 +- packages/api-contract/src/contract.ts | 6 +++--- 7 files changed, 18 insertions(+), 14 deletions(-) diff --git a/apps/api/src/account/delete.routes.test.ts b/apps/api/src/account/delete.routes.test.ts index b8adac35..03c417ca 100644 --- a/apps/api/src/account/delete.routes.test.ts +++ b/apps/api/src/account/delete.routes.test.ts @@ -70,8 +70,8 @@ const sessionHeaders = { }; function deleteRequest(body: { email: string; code: string }) { - return requestApp(app(), "/account", { - method: "DELETE", + return requestApp(app(), "/account/deletion", { + method: "POST", headers: sessionHeaders, body: JSON.stringify(body), }); @@ -269,7 +269,9 @@ describe("GET /account/deletion", () => { const response = await requestApp(app(), "/account/deletion"); expect(response.status).toBe(403); - expect(response.json()).toEqual({ error: "human_session_required" }); + expect(response.json<{ error: string }>()).toEqual({ + error: "human_session_required", + }); }); }); @@ -303,7 +305,7 @@ describe("POST /account/deletion/code", () => { }); }); -describe("DELETE /account", () => { +describe("POST /account/deletion", () => { it("deletes sole-owned organizations, leaves shared ones and erases the person", async () => { const owner = await insertUser("Owner"); const partner = await insertUser("Partner"); @@ -535,11 +537,11 @@ describe("DELETE /account", () => { code: "123456", }); expect(mismatch.status).toBe(400); - expect(mismatch.json()).toEqual({ error: "email_mismatch" }); + expect(mismatch.json<{ error: string }>()).toEqual({ error: "email_mismatch" }); const wrongCode = await deleteRequest({ email: owner.email, code: "000000" }); expect(wrongCode.status).toBe(400); - expect(wrongCode.json()).toEqual({ error: "invalid_code" }); + expect(wrongCode.json<{ error: string }>()).toEqual({ error: "invalid_code" }); expect(await organizationStatus(solo.organization.id)).toBe("active"); expect((await userRow(owner.id)).deletedAt).toBeNull(); diff --git a/apps/api/src/account/routes.ts b/apps/api/src/account/routes.ts index 2d50c9d6..23556a96 100644 --- a/apps/api/src/account/routes.ts +++ b/apps/api/src/account/routes.ts @@ -34,6 +34,8 @@ router.post( standardHeaders: true, legacyHeaders: false, message: { error: "too_many_requests" }, + // Per person: the route is authenticated, and IPs behind the web proxy are shared. + keyGenerator: (req) => (req as any).userId, }), ); diff --git a/apps/docs/content/docs/developers/errors.mdx b/apps/docs/content/docs/developers/errors.mdx index 5a979d29..c15dc7b0 100644 --- a/apps/docs/content/docs/developers/errors.mdx +++ b/apps/docs/content/docs/developers/errors.mdx @@ -57,7 +57,7 @@ See [Custom events](/developers/events) and [Sequences](/email-marketing/sequenc ## Account deletion errors -`DELETE /account` (dashboard session only): +`POST /account/deletion` (dashboard session only): | Status | Error | Meaning | | ------ | -------------------------- | -------------------------------------------------------------------------------------------------------------------------- | diff --git a/apps/docs/content/docs/workspace/account.mdx b/apps/docs/content/docs/workspace/account.mdx index c37e8dc6..1d9d386e 100644 --- a/apps/docs/content/docs/workspace/account.mdx +++ b/apps/docs/content/docs/workspace/account.mdx @@ -47,5 +47,5 @@ an anonymous id instead of your name or email. Signing up again with the same email creates a new, empty account. The REST API offers the same flow from a dashboard session only: -`GET /account/deletion`, `POST /account/deletion/code` and `DELETE /account`. +`GET /account/deletion`, `POST /account/deletion/code` and `POST /account/deletion`. It isn't available over MCP or with API keys. diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts index 7ea1a6e6..f0766b89 100644 --- a/apps/web/lib/api.ts +++ b/apps/web/lib/api.ts @@ -1224,8 +1224,8 @@ export function sendAccountDeletionCode() { /** Deletes the signed-in account (ADR 0009). Fails with `email_mismatch`, * `invalid_code` or `account_deletion_blocked`. */ export function deleteAccount(email: string, code: string) { - return organizationRequest("/account", { - method: "DELETE", + return organizationRequest("/account/deletion", { + method: "POST", body: JSON.stringify({ email, code }), }); } diff --git a/docs/decisions/0009-account-deletion-erases-the-person-and-their-contacts.md b/docs/decisions/0009-account-deletion-erases-the-person-and-their-contacts.md index f5dd34f9..466164df 100644 --- a/docs/decisions/0009-account-deletion-erases-the-person-and-their-contacts.md +++ b/docs/decisions/0009-account-deletion-erases-the-person-and-their-contacts.md @@ -30,7 +30,7 @@ workspace. organization and what happens to it, then asks the person to type their email and enter a code emailed to them (Better Auth's email OTP, `email-verification` type, reserved for this). The API - (`GET /account/deletion`, `POST /account/deletion/code`, `DELETE /account`) + (`GET /account/deletion`, `POST /account/deletion/code`, `POST /account/deletion`) accepts dashboard sessions only, with the same origin and CSRF checks as billing actions. MCP and API keys can't delete an account. - **Blockers.** Nothing changes while any of these hold, and the dialog diff --git a/packages/api-contract/src/contract.ts b/packages/api-contract/src/contract.ts index c358cf34..c1743d26 100644 --- a/packages/api-contract/src/contract.ts +++ b/packages/api-contract/src/contract.ts @@ -2288,11 +2288,11 @@ const accountContract = c.router({ }, summary: "Email a code to confirm deleting your account", description: - "Dashboard session only. Sends a one-time code, valid for 5 minutes, to the account's email. `DELETE /account` needs it.", + "Dashboard session only. Sends a one-time code, valid for 5 minutes, to the account's email. `POST /account/deletion` needs it.", }, delete: { - method: "DELETE", - path: "/account", + method: "POST", + path: "/account/deletion", body: deleteAccountBodySchema, responses: { 204: c.noBody(),