diff --git a/.changeset/email-drafts-and-publish.md b/.changeset/email-drafts-and-publish.md new file mode 100644 index 00000000..6c2a46ae --- /dev/null +++ b/.changeset/email-drafts-and-publish.md @@ -0,0 +1,6 @@ +--- +"sendlit": minor +"@sendlit/email-blocks": minor +--- + +Draft and publish for broadcast and sequence emails. While a broadcast or sequence is active (a running sequence or a scheduled broadcast), `sequences.updateEmail` saves subject and content changes as a draft (the email's `draft`, with `hasUnpublishedChanges` on the email and the sequence) and sends keep using the published version. New methods: `sequences.publishChanges` (with `scope: "in_flight" | "new_entrants"` for sequences), `sequences.discardChanges`, `sequences.emailVersions` and `sequences.restoreEmailVersion`. `@sendlit/email-blocks` adds `draft` and `hasUnpublishedChanges` to its sequence types. diff --git a/apps/api/drizzle/0031_email_drafts.sql b/apps/api/drizzle/0031_email_drafts.sql new file mode 100644 index 00000000..22b80c7e --- /dev/null +++ b/apps/api/drizzle/0031_email_drafts.sql @@ -0,0 +1,5 @@ +ALTER TABLE "email_content_versions" ADD COLUMN "published_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "ongoing_sequences" ADD COLUMN "pinned_versions" jsonb;--> statement-breakpoint +ALTER TABLE "sequence_emails" ADD COLUMN "draft_subject" text;--> statement-breakpoint +ALTER TABLE "sequence_emails" ADD COLUMN "draft_content" jsonb;--> statement-breakpoint +ALTER TABLE "sequence_emails" ADD COLUMN "draft_updated_at" timestamp with time zone; \ No newline at end of file diff --git a/apps/api/drizzle/meta/0031_snapshot.json b/apps/api/drizzle/meta/0031_snapshot.json new file mode 100644 index 00000000..362d54d9 --- /dev/null +++ b/apps/api/drizzle/meta/0031_snapshot.json @@ -0,0 +1,11708 @@ +{ + "id": "ae0fb770-31c2-4064-a2bc-41a6452c0ac5", + "prevId": "dba5e46d-3b7b-4b20-bc80-ef3afdb3f8d8", + "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 + }, + "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": 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": "'{}'" + }, + "pinned_versions": { + "name": "pinned_versions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "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": {} + }, + "ongoing_sequences_sequence_id_next_email_idx": { + "name": "ongoing_sequences_sequence_id_next_email_idx", + "columns": [ + { + "expression": "sequence_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_email_scheduled_time", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "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 + }, + "draft_subject": { + "name": "draft_subject", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "draft_content": { + "name": "draft_content", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "draft_updated_at": { + "name": "draft_updated_at", + "type": "timestamp with time zone", + "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 4dcbd245..1cc64cbe 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -218,6 +218,13 @@ "when": 1793380715744, "tag": "0030_waiting_contacts_index", "breakpoints": true + }, + { + "idx": 31, + "version": "7", + "when": 1793851987921, + "tag": "0031_email_drafts", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 6f635c1e..16581a32 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -7932,6 +7932,58 @@ "type": "string", "nullable": true }, + "draft": { + "type": "object", + "properties": { + "subject": { + "type": "string" + }, + "content": { + "type": "object", + "properties": { + "style": { + "type": "object", + "additionalProperties": { + "nullable": true + } + }, + "meta": { + "type": "object", + "additionalProperties": { + "nullable": true + } + }, + "content": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "nullable": true + } + } + } + }, + "required": [ + "style", + "meta", + "content" + ] + }, + "updatedAt": { + "type": "string", + "nullable": true + } + }, + "required": [ + "subject", + "content", + "updatedAt" + ], + "nullable": true + }, + "hasUnpublishedChanges": { + "type": "boolean" + }, "createdAt": { "type": "string", "nullable": true @@ -7952,6 +8004,9 @@ ] } }, + "hasUnpublishedChanges": { + "type": "boolean" + }, "pacing": { "type": "object", "properties": { @@ -8316,6 +8371,58 @@ "type": "string", "nullable": true }, + "draft": { + "type": "object", + "properties": { + "subject": { + "type": "string" + }, + "content": { + "type": "object", + "properties": { + "style": { + "type": "object", + "additionalProperties": { + "nullable": true + } + }, + "meta": { + "type": "object", + "additionalProperties": { + "nullable": true + } + }, + "content": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "nullable": true + } + } + } + }, + "required": [ + "style", + "meta", + "content" + ] + }, + "updatedAt": { + "type": "string", + "nullable": true + } + }, + "required": [ + "subject", + "content", + "updatedAt" + ], + "nullable": true + }, + "hasUnpublishedChanges": { + "type": "boolean" + }, "createdAt": { "type": "string", "nullable": true @@ -8336,6 +8443,9 @@ ] } }, + "hasUnpublishedChanges": { + "type": "boolean" + }, "pacing": { "type": "object", "properties": { @@ -8637,6 +8747,58 @@ "type": "string", "nullable": true }, + "draft": { + "type": "object", + "properties": { + "subject": { + "type": "string" + }, + "content": { + "type": "object", + "properties": { + "style": { + "type": "object", + "additionalProperties": { + "nullable": true + } + }, + "meta": { + "type": "object", + "additionalProperties": { + "nullable": true + } + }, + "content": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "nullable": true + } + } + } + }, + "required": [ + "style", + "meta", + "content" + ] + }, + "updatedAt": { + "type": "string", + "nullable": true + } + }, + "required": [ + "subject", + "content", + "updatedAt" + ], + "nullable": true + }, + "hasUnpublishedChanges": { + "type": "boolean" + }, "createdAt": { "type": "string", "nullable": true @@ -8657,6 +8819,9 @@ ] } }, + "hasUnpublishedChanges": { + "type": "boolean" + }, "pacing": { "type": "object", "properties": { @@ -8747,7 +8912,7 @@ ] }, "patch": { - "description": "A broadcast's title is its email's subject, so once the broadcast has been sent, changing the title returns 400 with the error \"This broadcast has already been sent\". `viewInBrowser` can be changed at any time; turning it off also takes down the web versions linked from emails already sent.\n\n**Authentication:** Requires a team API key or a user OAuth access token. Dashboard session cookies are also accepted.", + "description": "A broadcast's title is its email's published subject. While it's scheduled, a new title is saved as the email's draft subject (see `draft`) and becomes the title when the changes are published. Once the broadcast has been sent, changing the title returns 400 with the error \"This broadcast has already been sent\". `viewInBrowser` can be changed at any time; turning it off also takes down the web versions linked from emails already sent.\n\n**Authentication:** Requires a team API key or a user OAuth access token. Dashboard session cookies are also accepted.", "summary": "Update a broadcast or sequence", "tags": [ "Sequences" @@ -9536,6 +9701,58 @@ "type": "string", "nullable": true }, + "draft": { + "type": "object", + "properties": { + "subject": { + "type": "string" + }, + "content": { + "type": "object", + "properties": { + "style": { + "type": "object", + "additionalProperties": { + "nullable": true + } + }, + "meta": { + "type": "object", + "additionalProperties": { + "nullable": true + } + }, + "content": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "nullable": true + } + } + } + }, + "required": [ + "style", + "meta", + "content" + ] + }, + "updatedAt": { + "type": "string", + "nullable": true + } + }, + "required": [ + "subject", + "content", + "updatedAt" + ], + "nullable": true + }, + "hasUnpublishedChanges": { + "type": "boolean" + }, "createdAt": { "type": "string", "nullable": true @@ -9556,6 +9773,9 @@ ] } }, + "hasUnpublishedChanges": { + "type": "boolean" + }, "pacing": { "type": "object", "properties": { @@ -10056,6 +10276,58 @@ "type": "string", "nullable": true }, + "draft": { + "type": "object", + "properties": { + "subject": { + "type": "string" + }, + "content": { + "type": "object", + "properties": { + "style": { + "type": "object", + "additionalProperties": { + "nullable": true + } + }, + "meta": { + "type": "object", + "additionalProperties": { + "nullable": true + } + }, + "content": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "nullable": true + } + } + } + }, + "required": [ + "style", + "meta", + "content" + ] + }, + "updatedAt": { + "type": "string", + "nullable": true + } + }, + "required": [ + "subject", + "content", + "updatedAt" + ], + "nullable": true + }, + "hasUnpublishedChanges": { + "type": "boolean" + }, "createdAt": { "type": "string", "nullable": true @@ -10076,6 +10348,9 @@ ] } }, + "hasUnpublishedChanges": { + "type": "boolean" + }, "pacing": { "type": "object", "properties": { @@ -10403,6 +10678,58 @@ "type": "string", "nullable": true }, + "draft": { + "type": "object", + "properties": { + "subject": { + "type": "string" + }, + "content": { + "type": "object", + "properties": { + "style": { + "type": "object", + "additionalProperties": { + "nullable": true + } + }, + "meta": { + "type": "object", + "additionalProperties": { + "nullable": true + } + }, + "content": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "nullable": true + } + } + } + }, + "required": [ + "style", + "meta", + "content" + ] + }, + "updatedAt": { + "type": "string", + "nullable": true + } + }, + "required": [ + "subject", + "content", + "updatedAt" + ], + "nullable": true + }, + "hasUnpublishedChanges": { + "type": "boolean" + }, "createdAt": { "type": "string", "nullable": true @@ -10423,6 +10750,9 @@ ] } }, + "hasUnpublishedChanges": { + "type": "boolean" + }, "pacing": { "type": "object", "properties": { @@ -10751,40 +11081,95 @@ "type": "string", "nullable": true }, - "createdAt": { - "type": "string", - "nullable": true - }, - "updatedAt": { - "type": "string", - "nullable": true - } - }, - "required": [ - "emailId", - "subject", - "content", - "delayInMillis", - "published", - "createdAt", - "updatedAt" - ] - } - }, - "pacing": { - "type": "object", - "properties": { - "reason": { - "type": "string", - "enum": [ - "new_account" - ] - }, - "hourlyLimit": { - "type": "integer" - }, - "dailyLimit": { - "type": "integer" + "draft": { + "type": "object", + "properties": { + "subject": { + "type": "string" + }, + "content": { + "type": "object", + "properties": { + "style": { + "type": "object", + "additionalProperties": { + "nullable": true + } + }, + "meta": { + "type": "object", + "additionalProperties": { + "nullable": true + } + }, + "content": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "nullable": true + } + } + } + }, + "required": [ + "style", + "meta", + "content" + ] + }, + "updatedAt": { + "type": "string", + "nullable": true + } + }, + "required": [ + "subject", + "content", + "updatedAt" + ], + "nullable": true + }, + "hasUnpublishedChanges": { + "type": "boolean" + }, + "createdAt": { + "type": "string", + "nullable": true + }, + "updatedAt": { + "type": "string", + "nullable": true + } + }, + "required": [ + "emailId", + "subject", + "content", + "delayInMillis", + "published", + "createdAt", + "updatedAt" + ] + } + }, + "hasUnpublishedChanges": { + "type": "boolean" + }, + "pacing": { + "type": "object", + "properties": { + "reason": { + "type": "string", + "enum": [ + "new_account" + ] + }, + "hourlyLimit": { + "type": "integer" + }, + "dailyLimit": { + "type": "integer" }, "remaining": { "type": "integer" @@ -10899,7 +11284,7 @@ }, "/sequences/{sequenceId}/emails/{emailId}": { "patch": { - "description": "When templateId is supplied, it must identify a marketing template; transactional templates return 422 template_not_marketing. Edits to an active sequence's email apply to later sends only: the \"view in browser\" page of an email already sent keeps showing what was sent. A sent broadcast can't be edited; the request returns it unchanged.\n\n**Authentication:** Requires a team API key or a user OAuth access token. Dashboard session cookies are also accepted.", + "description": "When templateId is supplied, it must identify a marketing template; transactional templates return 422 template_not_marketing. While a broadcast or sequence is active (a running sequence or a scheduled broadcast), `subject` and `content` are saved as a draft (returned as the email's `draft`) and sends keep using the published version until the changes are published with POST /sequences/{sequenceId}/publish-changes. Otherwise nothing is sending, so they're saved directly, along with any open draft. Delay, published and tag action changes always apply right away. The \"view in browser\" page of an email already sent keeps showing what was sent. A sent broadcast can't be edited; the request returns it unchanged.\n\n**Authentication:** Requires a team API key or a user OAuth access token. Dashboard session cookies are also accepted.", "summary": "Update an email within a sequence", "tags": [ "Sequences" @@ -11750,6 +12135,58 @@ "type": "string", "nullable": true }, + "draft": { + "type": "object", + "properties": { + "subject": { + "type": "string" + }, + "content": { + "type": "object", + "properties": { + "style": { + "type": "object", + "additionalProperties": { + "nullable": true + } + }, + "meta": { + "type": "object", + "additionalProperties": { + "nullable": true + } + }, + "content": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "nullable": true + } + } + } + }, + "required": [ + "style", + "meta", + "content" + ] + }, + "updatedAt": { + "type": "string", + "nullable": true + } + }, + "required": [ + "subject", + "content", + "updatedAt" + ], + "nullable": true + }, + "hasUnpublishedChanges": { + "type": "boolean" + }, "createdAt": { "type": "string", "nullable": true @@ -11770,6 +12207,9 @@ ] } }, + "hasUnpublishedChanges": { + "type": "boolean" + }, "pacing": { "type": "object", "properties": { @@ -12121,6 +12561,58 @@ "type": "string", "nullable": true }, + "draft": { + "type": "object", + "properties": { + "subject": { + "type": "string" + }, + "content": { + "type": "object", + "properties": { + "style": { + "type": "object", + "additionalProperties": { + "nullable": true + } + }, + "meta": { + "type": "object", + "additionalProperties": { + "nullable": true + } + }, + "content": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "nullable": true + } + } + } + }, + "required": [ + "style", + "meta", + "content" + ] + }, + "updatedAt": { + "type": "string", + "nullable": true + } + }, + "required": [ + "subject", + "content", + "updatedAt" + ], + "nullable": true + }, + "hasUnpublishedChanges": { + "type": "boolean" + }, "createdAt": { "type": "string", "nullable": true @@ -12141,6 +12633,9 @@ ] } }, + "hasUnpublishedChanges": { + "type": "boolean" + }, "pacing": { "type": "object", "properties": { @@ -12246,10 +12741,10 @@ ] } }, - "/sequences/{sequenceId}/emails/{emailId}/test": { + "/sequences/{sequenceId}/publish-changes": { "post": { - "description": "Renders the email exactly like a real send (merge tags, footer, tracking) and sends it with a `[Test]` subject prefix through the delivery source the real send would use. Its \"view in browser\" link shows the email as it was when tested, not later edits. Merge tags use the recipient's contact data when they are a contact. Opens, clicks and the unsubscribe link in a test change nothing. Suppressed addresses are skipped. Every recipient counts as one test: by default a team can send 100 test emails in any 24 hours (50 in any 3 hours) and 20 per email per 24 hours, after which this returns 429 `test_send_limit_reached` or `test_send_email_limit_reached`. Tests through an organization's shared mailbox also count against the team's shared-delivery quota (429 `organization_team_quota_exhausted` or `organization_quota_exhausted`).\n\n**Authentication:** Requires a team API key or a user OAuth access token. Dashboard session cookies are also accepted.", - "summary": "Send a test of a broadcast or sequence email", + "description": "Makes the drafts of the given emails (or all of them) the version that sends use. Emails without unpublished changes are left alone, so publishing twice changes nothing. For a sequence, `scope` says whether contacts already in it get the new version at their next step (`in_flight`, the default) or keep the version they entered with (`new_entrants`). Each published version is kept in the email's history (GET /sequences/{sequenceId}/emails/{emailId}/versions), including the one it replaces. On SendLit Cloud the content is checked first, as when a sequence starts. 404 `email_not_found` for an email the sequence doesn't have; 409 `mail_already_sent` for a broadcast that has started sending (its unpublished changes are dropped then); 409 `draft_changed` when the draft was edited during the publish (publish again).\n\n**Authentication:** Requires a team API key or a user OAuth access token. Dashboard session cookies are also accepted.", + "summary": "Publish the unpublished changes of a broadcast or sequence", "tags": [ "Sequences" ], @@ -12261,14 +12756,6 @@ "schema": { "type": "string" } - }, - { - "name": "emailId", - "in": "path", - "required": true, - "schema": { - "type": "string" - } } ], "requestBody": { @@ -12278,14 +12765,21 @@ "schema": { "type": "object", "properties": { - "to": { + "emailIds": { "type": "array", "items": { - "type": "string", - "format": "email" + "type": "string" }, - "maxItems": 5, - "description": "Up to 5 addresses. Defaults to the signed-in user's address; API keys must set it." + "minItems": 1, + "description": "The emails to act on; omit for every email with unpublished changes" + }, + "scope": { + "type": "string", + "enum": [ + "in_flight", + "new_entrants" + ], + "description": "Sequences only. in_flight (default): contacts already in the sequence get the new version at their next step. new_entrants: they keep the version they entered with; only contacts who enter from now on get the new one." } } } @@ -12300,49 +12794,1459 @@ "schema": { "type": "object", "properties": { - "sent": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Addresses the test was sent to" + "sequenceId": { + "type": "string" }, - "skipped": { - "type": "array", - "items": { - "type": "object", - "properties": { - "email": { - "type": "string" + "type": { + "type": "string" + }, + "title": { + "type": "string" + }, + "status": { + "type": "string" + }, + "deliverySource": { + "discriminator": { + "propertyName": "type" + }, + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "organization" + ] + } }, - "reason": { - "type": "string", - "enum": [ - "suppressed" - ] - } + "required": [ + "type" + ] }, - "required": [ - "email", - "reason" - ] - }, - "description": "Addresses on the suppression list, which never get mail" - }, - "lastTestSentAt": { - "type": "string", + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "team" + ] + }, + "espId": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "type" + ] + } + ], "nullable": true }, - "eventSample": { + "triggerType": { "type": "string", "enum": [ - "last_event", - "none" - ] - } - }, - "required": [ - "sent", + "subscriber:added", + "tag:added", + "tag:removed", + "date:occurred", + "event:custom", + "contact:property_changed" + ], + "nullable": true + }, + "triggerData": { + "type": "string", + "nullable": true + }, + "triggerFilter": { + "nullable": true + }, + "reentry": { + "type": "string", + "enum": [ + "every_time", + "once", + "once_per_period" + ] + }, + "reentryPeriodDays": { + "type": "integer", + "nullable": true + }, + "filter": { + "nullable": true + }, + "excludeFilter": { + "nullable": true + }, + "entrantsCount": { + "type": "integer", + "minimum": 0 + }, + "emailsOrder": { + "type": "array", + "items": { + "type": "string" + } + }, + "entrants": { + "type": "array", + "items": { + "type": "string" + } + }, + "report": { + "nullable": true + }, + "viewInBrowser": { + "type": "boolean" + }, + "archivedAt": { + "type": "string", + "nullable": true + }, + "createdAt": { + "type": "string", + "nullable": true + }, + "updatedAt": { + "type": "string", + "nullable": true + }, + "emails": { + "type": "array", + "items": { + "type": "object", + "properties": { + "emailId": { + "type": "string" + }, + "subject": { + "type": "string" + }, + "content": { + "type": "object", + "properties": { + "style": { + "type": "object", + "additionalProperties": { + "nullable": true + } + }, + "meta": { + "type": "object", + "additionalProperties": { + "nullable": true + } + }, + "content": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "nullable": true + } + } + } + }, + "required": [ + "style", + "meta", + "content" + ] + }, + "delayInMillis": { + "type": "number" + }, + "published": { + "type": "boolean" + }, + "templateId": { + "type": "string", + "nullable": true + }, + "actionType": { + "type": "string", + "nullable": true + }, + "actionData": { + "type": "object", + "additionalProperties": { + "nullable": true + }, + "nullable": true + }, + "lastTestSentAt": { + "type": "string", + "nullable": true + }, + "contentScanVerdict": { + "type": "string", + "nullable": true + }, + "draft": { + "type": "object", + "properties": { + "subject": { + "type": "string" + }, + "content": { + "type": "object", + "properties": { + "style": { + "type": "object", + "additionalProperties": { + "nullable": true + } + }, + "meta": { + "type": "object", + "additionalProperties": { + "nullable": true + } + }, + "content": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "nullable": true + } + } + } + }, + "required": [ + "style", + "meta", + "content" + ] + }, + "updatedAt": { + "type": "string", + "nullable": true + } + }, + "required": [ + "subject", + "content", + "updatedAt" + ], + "nullable": true + }, + "hasUnpublishedChanges": { + "type": "boolean" + }, + "createdAt": { + "type": "string", + "nullable": true + }, + "updatedAt": { + "type": "string", + "nullable": true + } + }, + "required": [ + "emailId", + "subject", + "content", + "delayInMillis", + "published", + "createdAt", + "updatedAt" + ] + } + }, + "hasUnpublishedChanges": { + "type": "boolean" + }, + "pacing": { + "type": "object", + "properties": { + "reason": { + "type": "string", + "enum": [ + "new_account" + ] + }, + "hourlyLimit": { + "type": "integer" + }, + "dailyLimit": { + "type": "integer" + }, + "remaining": { + "type": "integer" + }, + "estimatedCompletionAt": { + "type": "string" + } + }, + "required": [ + "reason", + "hourlyLimit", + "dailyLimit", + "remaining", + "estimatedCompletionAt" + ], + "nullable": true + } + }, + "required": [ + "sequenceId", + "type", + "title", + "status", + "deliverySource", + "emailsOrder", + "entrants", + "viewInBrowser", + "createdAt", + "updatedAt", + "emails" + ] + } + } + } + }, + "400": { + "description": "400\n\nOr `credential_ambiguous`: the request sent more than one credential (for example an API key and a bearer token). Send exactly one.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + }, + "security": [ + { + "teamApiKey": [] + }, + { + "teamApiKeyStandardHeader": [] + }, + { + "userAccessToken": [] + }, + { + "dashboardSession": [] + }, + { + "dashboardSecureSession": [] + } + ] + } + }, + "/sequences/{sequenceId}/discard-changes": { + "post": { + "description": "Deletes the drafts of the given emails (or all of them), so they show the published version again. A broadcast's title goes back to its published subject. Sends are not affected.\n\n**Authentication:** Requires a team API key or a user OAuth access token. Dashboard session cookies are also accepted.", + "summary": "Discard the unpublished changes of a broadcast or sequence", + "tags": [ + "Sequences" + ], + "parameters": [ + { + "name": "sequenceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Body", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "emailIds": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "description": "The emails to act on; omit for every email with unpublished changes" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "200", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "sequenceId": { + "type": "string" + }, + "type": { + "type": "string" + }, + "title": { + "type": "string" + }, + "status": { + "type": "string" + }, + "deliverySource": { + "discriminator": { + "propertyName": "type" + }, + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "organization" + ] + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "team" + ] + }, + "espId": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "type" + ] + } + ], + "nullable": true + }, + "triggerType": { + "type": "string", + "enum": [ + "subscriber:added", + "tag:added", + "tag:removed", + "date:occurred", + "event:custom", + "contact:property_changed" + ], + "nullable": true + }, + "triggerData": { + "type": "string", + "nullable": true + }, + "triggerFilter": { + "nullable": true + }, + "reentry": { + "type": "string", + "enum": [ + "every_time", + "once", + "once_per_period" + ] + }, + "reentryPeriodDays": { + "type": "integer", + "nullable": true + }, + "filter": { + "nullable": true + }, + "excludeFilter": { + "nullable": true + }, + "entrantsCount": { + "type": "integer", + "minimum": 0 + }, + "emailsOrder": { + "type": "array", + "items": { + "type": "string" + } + }, + "entrants": { + "type": "array", + "items": { + "type": "string" + } + }, + "report": { + "nullable": true + }, + "viewInBrowser": { + "type": "boolean" + }, + "archivedAt": { + "type": "string", + "nullable": true + }, + "createdAt": { + "type": "string", + "nullable": true + }, + "updatedAt": { + "type": "string", + "nullable": true + }, + "emails": { + "type": "array", + "items": { + "type": "object", + "properties": { + "emailId": { + "type": "string" + }, + "subject": { + "type": "string" + }, + "content": { + "type": "object", + "properties": { + "style": { + "type": "object", + "additionalProperties": { + "nullable": true + } + }, + "meta": { + "type": "object", + "additionalProperties": { + "nullable": true + } + }, + "content": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "nullable": true + } + } + } + }, + "required": [ + "style", + "meta", + "content" + ] + }, + "delayInMillis": { + "type": "number" + }, + "published": { + "type": "boolean" + }, + "templateId": { + "type": "string", + "nullable": true + }, + "actionType": { + "type": "string", + "nullable": true + }, + "actionData": { + "type": "object", + "additionalProperties": { + "nullable": true + }, + "nullable": true + }, + "lastTestSentAt": { + "type": "string", + "nullable": true + }, + "contentScanVerdict": { + "type": "string", + "nullable": true + }, + "draft": { + "type": "object", + "properties": { + "subject": { + "type": "string" + }, + "content": { + "type": "object", + "properties": { + "style": { + "type": "object", + "additionalProperties": { + "nullable": true + } + }, + "meta": { + "type": "object", + "additionalProperties": { + "nullable": true + } + }, + "content": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "nullable": true + } + } + } + }, + "required": [ + "style", + "meta", + "content" + ] + }, + "updatedAt": { + "type": "string", + "nullable": true + } + }, + "required": [ + "subject", + "content", + "updatedAt" + ], + "nullable": true + }, + "hasUnpublishedChanges": { + "type": "boolean" + }, + "createdAt": { + "type": "string", + "nullable": true + }, + "updatedAt": { + "type": "string", + "nullable": true + } + }, + "required": [ + "emailId", + "subject", + "content", + "delayInMillis", + "published", + "createdAt", + "updatedAt" + ] + } + }, + "hasUnpublishedChanges": { + "type": "boolean" + }, + "pacing": { + "type": "object", + "properties": { + "reason": { + "type": "string", + "enum": [ + "new_account" + ] + }, + "hourlyLimit": { + "type": "integer" + }, + "dailyLimit": { + "type": "integer" + }, + "remaining": { + "type": "integer" + }, + "estimatedCompletionAt": { + "type": "string" + } + }, + "required": [ + "reason", + "hourlyLimit", + "dailyLimit", + "remaining", + "estimatedCompletionAt" + ], + "nullable": true + } + }, + "required": [ + "sequenceId", + "type", + "title", + "status", + "deliverySource", + "emailsOrder", + "entrants", + "viewInBrowser", + "createdAt", + "updatedAt", + "emails" + ] + } + } + } + }, + "400": { + "description": "`credential_ambiguous`: the request sent more than one credential (for example an API key and a bearer token). Send exactly one." + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + }, + "security": [ + { + "teamApiKey": [] + }, + { + "teamApiKeyStandardHeader": [] + }, + { + "userAccessToken": [] + }, + { + "dashboardSession": [] + }, + { + "dashboardSecureSession": [] + } + ] + } + }, + "/sequences/{sequenceId}/emails/{emailId}/versions": { + "get": { + "description": "The live version first, then the versions the email was published with before, newest first (at most 20). Restore one with POST /sequences/{sequenceId}/emails/{emailId}/versions/{versionId}/restore.\n\n**Authentication:** Requires a team API key or a user OAuth access token. Dashboard session cookies are also accepted.", + "summary": "List the published versions of a broadcast or sequence email", + "tags": [ + "Sequences" + ], + "parameters": [ + { + "name": "sequenceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "emailId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "200", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "versionId": { + "type": "string" + }, + "subject": { + "type": "string" + }, + "content": { + "type": "object", + "properties": { + "style": { + "type": "object", + "additionalProperties": { + "nullable": true + } + }, + "meta": { + "type": "object", + "additionalProperties": { + "nullable": true + } + }, + "content": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "nullable": true + } + } + } + }, + "required": [ + "style", + "meta", + "content" + ] + }, + "publishedAt": { + "type": "string", + "description": "When this version last went live" + }, + "live": { + "type": "boolean", + "description": "Whether sends use this version now" + } + }, + "required": [ + "versionId", + "subject", + "content", + "publishedAt", + "live" + ] + } + } + }, + "required": [ + "items" + ] + } + } + } + }, + "400": { + "description": "`credential_ambiguous`: the request sent more than one credential (for example an API key and a bearer token). Send exactly one." + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + }, + "security": [ + { + "teamApiKey": [] + }, + { + "teamApiKeyStandardHeader": [] + }, + { + "userAccessToken": [] + }, + { + "dashboardSession": [] + }, + { + "dashboardSecureSession": [] + } + ] + } + }, + "/sequences/{sequenceId}/emails/{emailId}/versions/{versionId}/restore": { + "post": { + "description": "Loads a published version's subject and content into the email's draft; publish it to roll back. Unless the broadcast or sequence is active, it's restored directly. Restoring the live version discards the draft. A broadcast's title follows the restored subject once it's published. 404 `version_not_found` for a version that isn't one of this email's published versions; 409 `mail_already_sent` for a broadcast that has started sending.\n\n**Authentication:** Requires a team API key or a user OAuth access token. Dashboard session cookies are also accepted.", + "summary": "Restore an earlier version of a broadcast or sequence email", + "tags": [ + "Sequences" + ], + "parameters": [ + { + "name": "sequenceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "emailId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "versionId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "200", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "sequenceId": { + "type": "string" + }, + "type": { + "type": "string" + }, + "title": { + "type": "string" + }, + "status": { + "type": "string" + }, + "deliverySource": { + "discriminator": { + "propertyName": "type" + }, + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "organization" + ] + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "team" + ] + }, + "espId": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "type" + ] + } + ], + "nullable": true + }, + "triggerType": { + "type": "string", + "enum": [ + "subscriber:added", + "tag:added", + "tag:removed", + "date:occurred", + "event:custom", + "contact:property_changed" + ], + "nullable": true + }, + "triggerData": { + "type": "string", + "nullable": true + }, + "triggerFilter": { + "nullable": true + }, + "reentry": { + "type": "string", + "enum": [ + "every_time", + "once", + "once_per_period" + ] + }, + "reentryPeriodDays": { + "type": "integer", + "nullable": true + }, + "filter": { + "nullable": true + }, + "excludeFilter": { + "nullable": true + }, + "entrantsCount": { + "type": "integer", + "minimum": 0 + }, + "emailsOrder": { + "type": "array", + "items": { + "type": "string" + } + }, + "entrants": { + "type": "array", + "items": { + "type": "string" + } + }, + "report": { + "nullable": true + }, + "viewInBrowser": { + "type": "boolean" + }, + "archivedAt": { + "type": "string", + "nullable": true + }, + "createdAt": { + "type": "string", + "nullable": true + }, + "updatedAt": { + "type": "string", + "nullable": true + }, + "emails": { + "type": "array", + "items": { + "type": "object", + "properties": { + "emailId": { + "type": "string" + }, + "subject": { + "type": "string" + }, + "content": { + "type": "object", + "properties": { + "style": { + "type": "object", + "additionalProperties": { + "nullable": true + } + }, + "meta": { + "type": "object", + "additionalProperties": { + "nullable": true + } + }, + "content": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "nullable": true + } + } + } + }, + "required": [ + "style", + "meta", + "content" + ] + }, + "delayInMillis": { + "type": "number" + }, + "published": { + "type": "boolean" + }, + "templateId": { + "type": "string", + "nullable": true + }, + "actionType": { + "type": "string", + "nullable": true + }, + "actionData": { + "type": "object", + "additionalProperties": { + "nullable": true + }, + "nullable": true + }, + "lastTestSentAt": { + "type": "string", + "nullable": true + }, + "contentScanVerdict": { + "type": "string", + "nullable": true + }, + "draft": { + "type": "object", + "properties": { + "subject": { + "type": "string" + }, + "content": { + "type": "object", + "properties": { + "style": { + "type": "object", + "additionalProperties": { + "nullable": true + } + }, + "meta": { + "type": "object", + "additionalProperties": { + "nullable": true + } + }, + "content": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "nullable": true + } + } + } + }, + "required": [ + "style", + "meta", + "content" + ] + }, + "updatedAt": { + "type": "string", + "nullable": true + } + }, + "required": [ + "subject", + "content", + "updatedAt" + ], + "nullable": true + }, + "hasUnpublishedChanges": { + "type": "boolean" + }, + "createdAt": { + "type": "string", + "nullable": true + }, + "updatedAt": { + "type": "string", + "nullable": true + } + }, + "required": [ + "emailId", + "subject", + "content", + "delayInMillis", + "published", + "createdAt", + "updatedAt" + ] + } + }, + "hasUnpublishedChanges": { + "type": "boolean" + }, + "pacing": { + "type": "object", + "properties": { + "reason": { + "type": "string", + "enum": [ + "new_account" + ] + }, + "hourlyLimit": { + "type": "integer" + }, + "dailyLimit": { + "type": "integer" + }, + "remaining": { + "type": "integer" + }, + "estimatedCompletionAt": { + "type": "string" + } + }, + "required": [ + "reason", + "hourlyLimit", + "dailyLimit", + "remaining", + "estimatedCompletionAt" + ], + "nullable": true + } + }, + "required": [ + "sequenceId", + "type", + "title", + "status", + "deliverySource", + "emailsOrder", + "entrants", + "viewInBrowser", + "createdAt", + "updatedAt", + "emails" + ] + } + } + } + }, + "400": { + "description": "`credential_ambiguous`: the request sent more than one credential (for example an API key and a bearer token). Send exactly one." + }, + "404": { + "description": "404", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "409": { + "description": "409", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + }, + "security": [ + { + "teamApiKey": [] + }, + { + "teamApiKeyStandardHeader": [] + }, + { + "userAccessToken": [] + }, + { + "dashboardSession": [] + }, + { + "dashboardSecureSession": [] + } + ] + } + }, + "/sequences/{sequenceId}/emails/{emailId}/test": { + "post": { + "description": "Renders the email exactly like a real send (merge tags, footer, tracking) and sends it with a `[Test]` subject prefix through the delivery source the real send would use. When the email has unpublished changes, the test sends the draft, so they can be checked before they're published. Its \"view in browser\" link shows the email as it was when tested, not later edits. Merge tags use the recipient's contact data when they are a contact. Opens, clicks and the unsubscribe link in a test change nothing. Suppressed addresses are skipped. Every recipient counts as one test: by default a team can send 100 test emails in any 24 hours (50 in any 3 hours) and 20 per email per 24 hours, after which this returns 429 `test_send_limit_reached` or `test_send_email_limit_reached`. Tests through an organization's shared mailbox also count against the team's shared-delivery quota (429 `organization_team_quota_exhausted` or `organization_quota_exhausted`).\n\n**Authentication:** Requires a team API key or a user OAuth access token. Dashboard session cookies are also accepted.", + "summary": "Send a test of a broadcast or sequence email", + "tags": [ + "Sequences" + ], + "parameters": [ + { + "name": "sequenceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "emailId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Body", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "to": { + "type": "array", + "items": { + "type": "string", + "format": "email" + }, + "maxItems": 5, + "description": "Up to 5 addresses. Defaults to the signed-in user's address; API keys must set it." + } + } + } + } + } + }, + "responses": { + "200": { + "description": "200", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "sent": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Addresses the test was sent to" + }, + "skipped": { + "type": "array", + "items": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "reason": { + "type": "string", + "enum": [ + "suppressed" + ] + } + }, + "required": [ + "email", + "reason" + ] + }, + "description": "Addresses on the suppression list, which never get mail" + }, + "lastTestSentAt": { + "type": "string", + "nullable": true + }, + "eventSample": { + "type": "string", + "enum": [ + "last_event", + "none" + ] + } + }, + "required": [ + "sent", "skipped", "lastTestSentAt" ] @@ -12643,6 +14547,58 @@ "type": "string", "nullable": true }, + "draft": { + "type": "object", + "properties": { + "subject": { + "type": "string" + }, + "content": { + "type": "object", + "properties": { + "style": { + "type": "object", + "additionalProperties": { + "nullable": true + } + }, + "meta": { + "type": "object", + "additionalProperties": { + "nullable": true + } + }, + "content": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "nullable": true + } + } + } + }, + "required": [ + "style", + "meta", + "content" + ] + }, + "updatedAt": { + "type": "string", + "nullable": true + } + }, + "required": [ + "subject", + "content", + "updatedAt" + ], + "nullable": true + }, + "hasUnpublishedChanges": { + "type": "boolean" + }, "createdAt": { "type": "string", "nullable": true @@ -12663,6 +14619,9 @@ ] } }, + "hasUnpublishedChanges": { + "type": "boolean" + }, "pacing": { "type": "object", "properties": { @@ -12987,6 +14946,58 @@ "type": "string", "nullable": true }, + "draft": { + "type": "object", + "properties": { + "subject": { + "type": "string" + }, + "content": { + "type": "object", + "properties": { + "style": { + "type": "object", + "additionalProperties": { + "nullable": true + } + }, + "meta": { + "type": "object", + "additionalProperties": { + "nullable": true + } + }, + "content": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "nullable": true + } + } + } + }, + "required": [ + "style", + "meta", + "content" + ] + }, + "updatedAt": { + "type": "string", + "nullable": true + } + }, + "required": [ + "subject", + "content", + "updatedAt" + ], + "nullable": true + }, + "hasUnpublishedChanges": { + "type": "boolean" + }, "createdAt": { "type": "string", "nullable": true @@ -13007,6 +15018,9 @@ ] } }, + "hasUnpublishedChanges": { + "type": "boolean" + }, "pacing": { "type": "object", "properties": { diff --git a/apps/api/src/abuse/content-hash.ts b/apps/api/src/abuse/content-hash.ts new file mode 100644 index 00000000..5764e396 --- /dev/null +++ b/apps/api/src/abuse/content-hash.ts @@ -0,0 +1,20 @@ +import { createHash } from "node:crypto"; + +/** JSON with sorted keys: Postgres `jsonb` doesn't keep key order, so the + * same content read back must hash the same. */ +function canonicalJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; + if (value && typeof value === "object") { + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonicalJson((value as any)[key])}`) + .join(",")}}`; + } + return JSON.stringify(value ?? null); +} + +/** Fingerprint of an email's subject and content: a scan verdict holds + * only for it, and stored versions are deduplicated by it. */ +export function contentHash(subject: string, content: unknown): string { + return createHash("sha256").update(canonicalJson({ subject, content })).digest("hex"); +} diff --git a/apps/api/src/abuse/content-scan.ts b/apps/api/src/abuse/content-scan.ts index de407b0d..cc796431 100644 --- a/apps/api/src/abuse/content-scan.ts +++ b/apps/api/src/abuse/content-scan.ts @@ -1,4 +1,3 @@ -import { createHash } from "node:crypto"; import Anthropic from "@anthropic-ai/sdk"; import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod"; import { eq } from "drizzle-orm"; @@ -8,9 +7,12 @@ import { db } from "../db/client"; import { sequenceEmails, teams } from "../db/schema"; import { captureError } from "../observability"; import { abuseControlsEnabled, readAbuseConfig } from "./config"; +import { contentHash } from "./content-hash"; import { pauseTeamForReview, raiseAbuseFlag } from "./flags"; import { recordAbuseEvent } from "./telemetry"; +export { contentHash }; + export type ScanVerdict = "clean" | "review" | "blocked"; export type ScanResult = { verdict: ScanVerdict; reasons: string[] }; @@ -61,24 +63,6 @@ function hostOf(url: string): string | null { } } -/** JSON with sorted keys: Postgres `jsonb` doesn't keep key order, so the - * same content read back must hash the same. */ -function canonicalJson(value: unknown): string { - if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; - if (value && typeof value === "object") { - return `{${Object.keys(value) - .sort() - .map((key) => `${JSON.stringify(key)}:${canonicalJson((value as any)[key])}`) - .join(",")}}`; - } - return JSON.stringify(value ?? null); -} - -/** Fingerprint of what a scan looked at; a verdict holds only for it. */ -export function contentHash(subject: string, content: unknown): string { - return createHash("sha256").update(canonicalJson({ subject, content })).digest("hex"); -} - /** * Fast, free checks for the usual phishing and spam tells: credential lures, * brand impersonation with off-brand links, link text naming one domain while diff --git a/apps/api/src/automation/process-ongoing-sequence.ts b/apps/api/src/automation/process-ongoing-sequence.ts index 26dde984..820e2c52 100644 --- a/apps/api/src/automation/process-ongoing-sequence.ts +++ b/apps/api/src/automation/process-ongoing-sequence.ts @@ -29,7 +29,10 @@ import { quotaExhaustionDeferral, SendDeferredError } from "../mail/rate-limit"; import { EmailRenderError } from "../mail/render"; import { sendMail } from "../mail/send"; import { captureError, captureEvent } from "../observability"; -import { getOrCreateContentVersion } from "../sequences/content-versions"; +import { + getOrCreateContentVersion, + versionForContact, +} from "../sequences/content-versions"; import logger from "../services/log"; import { withoutProviderTracking } from "../settings/esp/provider-capabilities"; import { trackingOriginForTeam } from "../settings/tracking-domain/queries"; @@ -151,7 +154,9 @@ export async function processOngoingSequence(ongoingSequenceId: string) { contact, sequence: sequenceRow, ongoingSequence, - email: nextEmail, + // The published version, never a draft (#184), or the one this + // contact was kept on when changes went to new entrants only. + email: await versionForContact(nextEmail, ongoingSequence.pinnedVersions), }); if (sequenceRow.type === "sequence") { reportEarlySend(ongoingSequence, sequenceRow, nextEmail); diff --git a/apps/api/src/automation/queries.ts b/apps/api/src/automation/queries.ts index 671a7b52..2d656ad0 100644 --- a/apps/api/src/automation/queries.ts +++ b/apps/api/src/automation/queries.ts @@ -334,16 +334,24 @@ async function recordEntry( } export async function lockBroadcast(sequenceRowId: string, entrants: string[]) { - await db - .update(sequences) - .set({ - entrants, - report: sql`jsonb_set(coalesce(${sequences.report}, '{}'::jsonb), '{broadcast}', ${JSON.stringify( - { lockedAt: Date.now(), sentAt: null }, - )}::jsonb)`, - updatedAt: new Date(), - }) - .where(eq(sequences.id, sequenceRowId)); + await db.transaction(async (tx) => { + await tx + .update(sequences) + .set({ + entrants, + report: sql`jsonb_set(coalesce(${sequences.report}, '{}'::jsonb), '{broadcast}', ${JSON.stringify( + { lockedAt: Date.now(), sentAt: null }, + )}::jsonb)`, + updatedAt: new Date(), + }) + .where(eq(sequences.id, sequenceRowId)); + // It sends its published version; unpublished changes can never go out + // now, so they're dropped rather than left looking publishable (#184). + await tx + .update(sequenceEmails) + .set({ draftSubject: null, draftContent: null, draftUpdatedAt: null }) + .where(eq(sequenceEmails.sequenceId, sequenceRowId)); + }); } export async function markBroadcastSent(sequenceId: string) { diff --git a/apps/api/src/db/schema-core.ts b/apps/api/src/db/schema-core.ts index f628830c..2b8b9836 100644 --- a/apps/api/src/db/schema-core.ts +++ b/apps/api/src/db/schema-core.ts @@ -1446,6 +1446,13 @@ export const sequenceEmails = pgTable( templateId: text("template_id"), actionType: text("action_type"), // tag:add | tag:remove actionData: jsonb("action_data"), + /** The working draft of a started sequence's or broadcast's email + * (#184). `subject` and `content` above are the published version that + * sends use; edits are saved here until they're published. Both are set + * together, or both are null when there are no unpublished changes. */ + draftSubject: text("draft_subject"), + draftContent: jsonb("draft_content"), + draftUpdatedAt: timestamp("draft_updated_at", { withTimezone: true }), /** When a test of this email was last sent (`POST .../test`). */ lastTestSentAt: timestamp("last_test_sent_at", { withTimezone: true }), /** Hosted content scan (`abuse/content-scan.ts`): `clean`, `review` @@ -1471,9 +1478,9 @@ export const sequenceEmails = pgTable( }), ); -/** What a broadcast or sequence email looked like when it was sent: one - * row per distinct subject and content (`contentHash` from - * `abuse/content-scan.ts`), never updated. A send's "view in browser" link +/** What a broadcast or sequence email looked like when it was sent or + * published: one row per distinct subject and content (`contentHash` from + * `abuse/content-scan.ts`), whose subject and content never change. A send's "view in browser" link * points at its row, so later edits don't change what earlier recipients * see. Deleted with the email. */ export const emailContentVersions = pgTable( @@ -1494,6 +1501,10 @@ export const emailContentVersions = pgTable( contentHash: text("content_hash").notNull(), subject: text("subject").notNull(), content: jsonb("content").notNull(), + /** When this version last went live (#184): set on publish, so the + * published versions form the email's history and can be restored. + * Null for versions that were only sent as tests. */ + publishedAt: timestamp("published_at", { withTimezone: true }), createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(), }, (table) => ({ @@ -1568,6 +1579,10 @@ export const ongoingSequences = pgTable( * contacts get the day's capacity while an ESP warms up. */ priority: bigint("priority", { mode: "number" }).notNull().default(0), sentEmailIds: text("sent_email_ids").array().notNull().default([]), + /** Emails this contact keeps getting in an earlier version (#184): + * public `emailId` to the internal `email_content_versions.id`, set + * when changes are published for new entrants only. */ + pinnedVersions: jsonb("pinned_versions").$type>(), /** Snapshot of the custom event that enrolled the contact, rendered as * `{{ event.* }}`. Null for every other trigger. */ triggerEvent: jsonb("trigger_event").$type(), diff --git a/apps/api/src/mcp/policy.ts b/apps/api/src/mcp/policy.ts index e2bc1cbb..49dd6dbe 100644 --- a/apps/api/src/mcp/policy.ts +++ b/apps/api/src/mcp/policy.ts @@ -85,6 +85,10 @@ const operationScopes = { "sequences.unarchive": MCP_SCOPES.sequencesWrite, "sequences.addEmail": MCP_SCOPES.sequencesWrite, "sequences.updateEmail": MCP_SCOPES.sequencesWrite, + "sequences.publishChanges": MCP_SCOPES.sequencesWrite, + "sequences.discardChanges": MCP_SCOPES.sequencesWrite, + "sequences.emailVersions": MCP_SCOPES.sequencesRead, + "sequences.restoreEmailVersion": MCP_SCOPES.sequencesWrite, "contacts.import": MCP_SCOPES.contactsWrite, "suppressions.create": MCP_SCOPES.suppressionsWrite, "forms.list": MCP_SCOPES.contactsRead, diff --git a/apps/api/src/mcp/tools/sequences.test.ts b/apps/api/src/mcp/tools/sequences.test.ts index 979de232..d0650640 100644 --- a/apps/api/src/mcp/tools/sequences.test.ts +++ b/apps/api/src/mcp/tools/sequences.test.ts @@ -22,6 +22,10 @@ const mocks = { getWaitingContacts: vi.fn(), setBroadcastArchived: vi.fn(), sendSequenceEmailTest: vi.fn(), + publishEmailChanges: vi.fn(), + discardEmailChanges: vi.fn(), + listEmailVersions: vi.fn(), + restoreEmailVersion: vi.fn(), }; class InvalidCursorError extends Error {} @@ -49,6 +53,12 @@ mock.module("../../sequences/queries", () => ({ setBroadcastArchived: mocks.setBroadcastArchived, InvalidCursorError, })); +mock.module("../../sequences/publish", () => ({ + publishEmailChanges: mocks.publishEmailChanges, + discardEmailChanges: mocks.discardEmailChanges, + listEmailVersions: mocks.listEmailVersions, + restoreEmailVersion: mocks.restoreEmailVersion, +})); mock.module("../../sequences/test-send", () => ({ sendSequenceEmailTest: mocks.sendSequenceEmailTest, })); @@ -97,6 +107,10 @@ describe("MCP sequence tools", () => { "update_sequence", "add_sequence_email", "update_sequence_email", + "publish_sequence_changes", + "discard_sequence_changes", + "list_sequence_email_versions", + "restore_sequence_email_version", "send_sequence_email_test", "delete_sequence_email", "preview_sequence_deletion", @@ -112,7 +126,11 @@ describe("MCP sequence tools", () => { ]); for (const tool of tools.values()) expect(tool.title).toBeTruthy(); // Destructive tools advertise `confirm`, which the kit requires. - for (const name of ["delete_sequence", "delete_sequence_email"]) { + for (const name of [ + "delete_sequence", + "delete_sequence_email", + "discard_sequence_changes", + ]) { const tool = tools.get(name)!; expect(tool.risk).toBe("destructive"); expect((tool.inputSchema as any).shape.confirm).toBeDefined(); @@ -158,7 +176,13 @@ describe("MCP sequence tools", () => { ]); mocks.countSequences.mockResolvedValue(1); await expect(call("list_sequences", { type: "sequence" })).resolves.toEqual({ - items: [{ sequenceId: "seq-1", emails: [{ emailId: "e1" }] }], + items: [ + { + sequenceId: "seq-1", + emails: [{ emailId: "e1", draft: null, hasUnpublishedChanges: false }], + hasUnpublishedChanges: false, + }, + ], total: 1, }); expect(mocks.listSequences).toHaveBeenCalledWith( @@ -167,6 +191,88 @@ describe("MCP sequence tools", () => { expect(mocks.countSequences).toHaveBeenCalledWith("team-1", "sequence", undefined); }); + it("returns an email's draft separately from the published version", async () => { + mocks.getSequenceBySequenceId.mockResolvedValue({ + sequenceId: "seq-1", + emails: [ + { + emailId: "e1", + subject: "Live", + content: { content: [] }, + draftSubject: "Edited", + draftContent: { content: [{ blockType: "text" }] }, + draftUpdatedAt: new Date("2026-11-01T00:00:00Z"), + }, + ], + }); + const result: any = await call("get_sequence", { sequenceId: "seq-1" }); + expect(result.hasUnpublishedChanges).toBe(true); + expect(result.emails[0]).toMatchObject({ + subject: "Live", + draft: { subject: "Edited", content: { content: [{ blockType: "text" }] } }, + hasUnpublishedChanges: true, + }); + expect(result.emails[0].draftSubject).toBeUndefined(); + }); + + it("publishes changes only through publish_sequence_changes", async () => { + mocks.publishEmailChanges.mockResolvedValue({ sequenceId: "seq-1", emails: [] }); + await expect( + call("publish_sequence_changes", { + sequenceId: "seq-1", + emailIds: ["e1"], + scope: "new_entrants", + }), + ).resolves.toMatchObject({ sequenceId: "seq-1", hasUnpublishedChanges: false }); + expect(mocks.publishEmailChanges).toHaveBeenCalledWith({ + teamId: "team-1", + sequenceId: "seq-1", + emailIds: ["e1"], + scope: "new_entrants", + }); + + mocks.publishEmailChanges.mockRejectedValue(new Error("mail_already_sent")); + await expect( + call("publish_sequence_changes", { sequenceId: "seq-1" }), + ).resolves.toMatchObject({ + error: { code: "conflict", safeDetails: { reason: "mail_already_sent" } }, + }); + }); + + it("lists and restores published versions", async () => { + mocks.listEmailVersions.mockResolvedValue([ + { + versionId: "ver_1", + subject: "Live", + content: { content: [] }, + publishedAt: new Date("2026-11-01T00:00:00Z"), + live: true, + }, + ]); + await expect( + call("list_sequence_email_versions", { sequenceId: "seq-1", emailId: "e1" }), + ).resolves.toEqual({ + items: [ + { + versionId: "ver_1", + subject: "Live", + content: { content: [] }, + publishedAt: "2026-11-01T00:00:00.000Z", + live: true, + }, + ], + }); + + mocks.restoreEmailVersion.mockRejectedValue(new Error("version_not_found")); + await expect( + call("restore_sequence_email_version", { + sequenceId: "seq-1", + emailId: "e1", + versionId: "ver_x", + }), + ).resolves.toMatchObject({ error: { code: "not_found" } }); + }); + it("checks sequence ownership before returning stats", async () => { mocks.getSequenceBySequenceId.mockResolvedValueOnce(null); await expect( diff --git a/apps/api/src/mcp/tools/sequences.ts b/apps/api/src/mcp/tools/sequences.ts index 427c3fc4..99403cdb 100644 --- a/apps/api/src/mcp/tools/sequences.ts +++ b/apps/api/src/mcp/tools/sequences.ts @@ -1,9 +1,12 @@ import { archivedFilters, contactFilterSchema, + discardSequenceChangesBodySchema, emailContentInputSchema, + publishSequenceChangesBodySchema, sequenceDeletionPreviewSchema, sequenceEmailTestResultSchema, + sequenceEmailVersionSchema, updateSequenceBodySchema, } from "@sendlit/api-contract"; import { z } from "zod"; @@ -15,6 +18,16 @@ import { broadcastArchiveErrors, sequenceDeleteBlockerMessages, } from "../../sequences/delete-blockers"; +import { + sequenceHasUnpublishedChanges, + toPublicSequenceEmail, +} from "../../sequences/drafts"; +import { + discardEmailChanges, + listEmailVersions, + publishEmailChanges, + restoreEmailVersion, +} from "../../sequences/publish"; import type { HydratedSequence } from "../../sequences/queries"; import { addMailToSequence, @@ -42,6 +55,7 @@ import { import { sendSequenceEmailTest } from "../../sequences/test-send"; import { MAILING_ADDRESS_REQUIRED } from "../../settings/general/constants"; import { omitInternal } from "../../utils/public"; +import { serializeDates } from "../../utils/serialize"; import type { SendLitMcpTool } from "../context"; import { dedicatedTool } from "../tool"; import { @@ -71,9 +85,8 @@ function toPublicSequence(sequence: HydratedSequence) { "deliverySourceType", "deliverySourceIntent", ]), - emails: sequence.emails.map((email) => - omitInternal(email, ["sequenceId", "contentScanHash", "contentScanReasons"]), - ), + emails: sequence.emails.map(toPublicSequenceEmail), + hasUnpublishedChanges: sequenceHasUnpublishedChanges(sequence), }; } @@ -83,6 +96,7 @@ const NOT_FOUND_REASONS = new Set([ "template_not_found", "team_not_found", "item_not_found", + "version_not_found", ]); const RATE_LIMITED_REASONS = new Set([ "test_send_limit_reached", @@ -96,6 +110,7 @@ const CONFLICT_REASONS = new Set([ "mail_already_sent", "broadcast_archived", "broadcast_held_for_review", + "draft_changed", "esp_cannot_change_after_start", "esp_not_configured", "duplicate_title", @@ -326,7 +341,7 @@ export const sequenceTools = (): SendLitMcpTool[] => [ operationId: "sequences.updateEmail", risk: "write", description: - 'Updates one email within a sequence/broadcast: subject, content, delay, publish status, or the tag action fired on send. Edits apply to later sends only; the "view in browser" page of an email already sent keeps showing what was sent. A sent broadcast can\'t be edited.', + "Updates one email within a sequence/broadcast: subject, content, delay, publish status, or the tag action fired on send. While the broadcast or sequence is active (running, or scheduled), subject and content are saved as a draft (the email's `draft`) and contacts keep getting the published version until you call publish_sequence_changes; show the user the draft and confirm before publishing. Otherwise they're saved directly. Delay, published and tag action changes apply right away. The \"view in browser\" page of an email already sent keeps showing what was sent. A sent broadcast can't be edited.", inputSchema: z.object({ sequenceId: z.string(), emailId: z.string(), @@ -364,13 +379,116 @@ export const sequenceTools = (): SendLitMcpTool[] => [ }, }), + dedicatedTool({ + name: "publish_sequence_changes", + title: "Publish Sequence Changes", + operationId: "sequences.publishChanges", + risk: "write", + description: + "Publishes the unpublished changes (drafts) of a broadcast or sequence, so sends use them: the given emails, or every email with a draft. Confirm with the user first; this changes what contacts receive. For a sequence, scope in_flight (default) also gives the new version to contacts already in it at their next step; new_entrants keeps them on the version they entered with. The replaced version stays in the email's history (list_sequence_email_versions). Errors: mail_already_sent for a broadcast that has started sending, draft_changed when the draft was edited meanwhile, content_blocked or content_held_for_review from SendLit Cloud's content check.", + inputSchema: z.object({ + sequenceId: z.string(), + emailIds: publishSequenceChangesBodySchema.shape.emailIds, + scope: publishSequenceChangesBodySchema.shape.scope, + }), + outputSchema: sequenceSchema, + annotations: { openWorldHint: false }, + async handler({ context, args }) { + try { + const sequence = await publishEmailChanges({ + teamId: context.teamId, + ...args, + }); + if (!sequence) return notFound(); + return toPublicSequence(sequence); + } catch (err) { + return sequenceError(err); + } + }, + }), + + dedicatedTool({ + name: "discard_sequence_changes", + title: "Discard Sequence Changes", + operationId: "sequences.discardChanges", + risk: "destructive", + description: + "Deletes the unpublished changes (drafts) of the given emails of a broadcast or sequence, or of all of them, so they show the published version again. The edits can't be recovered. Sends are not affected.", + inputSchema: z.object({ + sequenceId: z.string(), + emailIds: discardSequenceChangesBodySchema.shape.emailIds, + }), + outputSchema: sequenceSchema, + annotations: { openWorldHint: false }, + async handler({ context, args }) { + try { + const sequence = await discardEmailChanges({ + teamId: context.teamId, + ...args, + }); + if (!sequence) return notFound(); + return toPublicSequence(sequence); + } catch (err) { + return sequenceError(err); + } + }, + }), + + dedicatedTool({ + name: "list_sequence_email_versions", + title: "List Sequence Email Versions", + operationId: "sequences.emailVersions", + risk: "read", + description: + "Lists the versions a broadcast or sequence email has been published with: the live one first, then earlier ones newest first (at most 20), with their subject and content. Restore one with restore_sequence_email_version.", + inputSchema: z.object({ sequenceId: z.string(), emailId: z.string() }), + outputSchema: z.object({ items: z.array(sequenceEmailVersionSchema) }), + annotations: { openWorldHint: false }, + async handler({ context, args }) { + const versions = await listEmailVersions({ + teamId: context.teamId, + ...args, + }); + if (!versions) return notFound(); + return { items: serializeDates(versions) }; + }, + }), + + dedicatedTool({ + name: "restore_sequence_email_version", + title: "Restore Sequence Email Version", + operationId: "sequences.restoreEmailVersion", + risk: "write", + description: + "Loads an earlier published version of an email (from list_sequence_email_versions) into its draft. Nothing changes for contacts until the draft is published with publish_sequence_changes. Unless the broadcast or sequence is active, it's restored directly.", + inputSchema: z.object({ + sequenceId: z.string(), + emailId: z.string(), + versionId: z.string(), + }), + outputSchema: sequenceSchema, + annotations: { openWorldHint: false }, + async handler({ context, args }) { + try { + const sequence = await restoreEmailVersion({ + teamId: context.teamId, + ...args, + }); + if (!sequence) return notFound(); + return toPublicSequence(sequence); + } catch (err) { + return sequenceError(err); + } + }, + }), + dedicatedTool({ name: "send_sequence_email_test", title: "Send Sequence Email Test", operationId: "sequences.sendEmailTest", risk: "write", description: - "Sends a test of one broadcast/sequence email, rendered exactly like a real send (merge tags, footer, tracking) with a [Test] subject prefix; its \"view in browser\" link shows the email as tested. Defaults to the current user's own address (OAuth sessions only; API-key sessions must supply `to`). Suppressed addresses are skipped; opens, clicks and the unsubscribe link in a test change nothing. Every recipient counts as one test: by default 100 per team in any 24 hours (50 in any 3 hours) and 20 per email per 24 hours (errors `test_send_limit_reached`, `test_send_email_limit_reached`). Tests through an organization's shared mailbox also use the team's shared-delivery quota.", + "Sends a test of one broadcast/sequence email, rendered exactly like a real send (merge tags, footer, tracking) with a [Test] subject prefix. It sends the draft when the email has unpublished changes, so they can be checked before publishing; its \"view in browser\" link shows the email as tested. Defaults to the current user's own address (OAuth sessions only; API-key sessions must supply `to`). Suppressed addresses are skipped; opens, clicks and the unsubscribe link in a test change nothing. Every recipient counts as one test: by default 100 per team in any 24 hours (50 in any 3 hours) and 20 per email per 24 hours (errors `test_send_limit_reached`, `test_send_email_limit_reached`). Tests through an organization's shared mailbox also use the team's shared-delivery quota.", inputSchema: z.object({ sequenceId: z.string(), emailId: z.string(), diff --git a/apps/api/src/media/email-content.ts b/apps/api/src/media/email-content.ts index 7fdf7b56..2b4897e9 100644 --- a/apps/api/src/media/email-content.ts +++ b/apps/api/src/media/email-content.ts @@ -139,13 +139,22 @@ export async function ensureUploadedMedia({ }); } +/** + * Seals the uploaded images in `content` into the media library, points the + * resource's media references at them and returns `content` with sealed + * URLs. Images in `alsoReferenced` (a resource's other copy, such as the + * published version of an email whose draft is being saved) stay + * referenced too, so the media library can't delete them while in use. + */ export async function syncEmailContentMediaReferences({ teamId, content, + alsoReferenced, resource, }: { teamId: string; content?: EmailContent | null; + alsoReferenced?: EmailContent | null; resource: MediaReferenceResource; }): Promise { const mediaLitImages = extractMediaLitImagesFromEmailContent(content); @@ -161,6 +170,15 @@ export async function syncEmailContentMediaReferences({ mediaRows.push(row); updateImageSrc(reconciledContent, image.mediaLitId, row.url); } + for (const image of extractMediaLitImagesFromEmailContent(alsoReferenced)) { + mediaRows.push( + await ensureUploadedMedia({ + teamId, + mediaLitId: image.mediaLitId, + alt: image.alt, + }), + ); + } await replaceMediaReferencesForResource({ teamId, diff --git a/apps/api/src/observability/instance.ts b/apps/api/src/observability/instance.ts index abc43f77..17db8370 100644 --- a/apps/api/src/observability/instance.ts +++ b/apps/api/src/observability/instance.ts @@ -22,6 +22,11 @@ const PROPERTY_ALLOWLIST = new Set([ "sequence_id", "sequence_type", "sequence_status", + // Draft and publish (#184): how many emails a publish or discard covered, + // who got the changes (in_flight | new_entrants) and a restored version. + "emails_count", + "publish_scope", + "version_id", "ongoing_sequence_id", "template_id", "segment_id", diff --git a/apps/api/src/sequences/content-versions.ts b/apps/api/src/sequences/content-versions.ts index f220af7f..4c8e22fe 100644 --- a/apps/api/src/sequences/content-versions.ts +++ b/apps/api/src/sequences/content-versions.ts @@ -85,3 +85,32 @@ export async function getContentVersion( .limit(1); return row ?? null; } + +/** + * The version a contact gets of `email`: the one pinned for them when + * changes were published for new entrants only, otherwise the published + * one. A pin to a version that's gone falls back to the published one. + */ +export async function versionForContact< + T extends Pick< + typeof sequenceEmails.$inferSelect, + "id" | "emailId" | "subject" | "content" + >, +>(email: T, pinnedVersions: Record | null | undefined): Promise { + const pinned = pinnedVersions?.[email.emailId]; + if (!pinned) return email; + const [version] = await db + .select({ + subject: emailContentVersions.subject, + content: emailContentVersions.content, + }) + .from(emailContentVersions) + .where( + and( + eq(emailContentVersions.id, pinned), + eq(emailContentVersions.sequenceEmailId, email.id), + ), + ) + .limit(1); + return version ? { ...email, ...version } : email; +} diff --git a/apps/api/src/sequences/drafts.ts b/apps/api/src/sequences/drafts.ts new file mode 100644 index 00000000..90defa69 --- /dev/null +++ b/apps/api/src/sequences/drafts.ts @@ -0,0 +1,97 @@ +import { contentHash } from "../abuse/content-hash"; +import type { sequenceEmails, sequences } from "../db/schema"; +import { omitInternal } from "../utils/public"; + +/** + * Draft and publish for broadcast and sequence emails (#184, ADR 0011). + * + * `sequence_emails.subject` and `content` are the published version: every + * send renders from them (or from a version pinned for the contact, see + * `ongoing_sequences.pinned_versions`). While a broadcast or sequence is + * active, edits to subject and content are saved as a draft in + * `draft_subject` and `draft_content` and only go live when they're + * published. Otherwise nothing is sending, so edits are saved directly (a + * paused broadcast is rescheduled, and a paused sequence is rescanned, by + * `startSequence`). + */ + +type EmailRow = typeof sequenceEmails.$inferSelect; + +/** Whether edits to this sequence's emails are saved as drafts. */ +export function editsAreDrafts( + sequence: Pick, +) { + return sequence.status === "active"; +} + +export function hasDraft( + email: Pick, +): email is Pick & { + draftSubject: string; + draftContent: unknown; +} { + return email.draftSubject != null && email.draftContent != null; +} + +/** The subject and content the editor works on: the draft when there is + * one, otherwise the published version. */ +export function workingCopy( + email: Pick, +): { subject: string; content: unknown } { + return hasDraft(email) + ? { subject: email.draftSubject, content: email.draftContent } + : { subject: email.subject, content: email.content }; +} + +/** + * The columns to write when `subject` and/or `content` are saved as a + * draft. Starts from the current working copy, so saving only the content + * keeps a drafted subject. A draft equal to the published version is + * dropped, so "unpublished changes" means the two really differ. + */ +export function draftPatch( + email: Pick, + edit: { subject?: string; content?: unknown }, +): Pick { + const current = workingCopy(email); + const subject = edit.subject ?? current.subject; + const content = edit.content ?? current.content; + if (contentHash(subject, content) === contentHash(email.subject, email.content)) { + return { draftSubject: null, draftContent: null, draftUpdatedAt: null }; + } + return { draftSubject: subject, draftContent: content, draftUpdatedAt: new Date() }; +} + +/** Clears the draft. */ +export const noDraft: Pick< + EmailRow, + "draftSubject" | "draftContent" | "draftUpdatedAt" +> = Object.freeze({ draftSubject: null, draftContent: null, draftUpdatedAt: null }); + +/** An email as REST and MCP return it: internal columns dropped and the + * draft, if any, as `draft`. */ +export function toPublicSequenceEmail(email: EmailRow) { + const draft = hasDraft(email) + ? { + subject: email.draftSubject, + content: email.draftContent, + updatedAt: email.draftUpdatedAt, + } + : null; + return { + ...omitInternal(email, [ + "sequenceId", + "contentScanHash", + "contentScanReasons", + "draftSubject", + "draftContent", + "draftUpdatedAt", + ]), + draft, + hasUnpublishedChanges: draft !== null, + }; +} + +export function sequenceHasUnpublishedChanges(sequence: { emails: EmailRow[] }) { + return sequence.emails.some(hasDraft); +} diff --git a/apps/api/src/sequences/publish.test.ts b/apps/api/src/sequences/publish.test.ts new file mode 100644 index 00000000..bc558fc4 --- /dev/null +++ b/apps/api/src/sequences/publish.test.ts @@ -0,0 +1,416 @@ +import { beforeEach, describe, expect, it, type Mock, mock, vi } from "bun:test"; +import { eq } from "drizzle-orm"; + +mock.module("../db/client", async () => { + const { makeTestDb } = await import("../test/db.js"); + return { db: await makeTestDb() }; +}); +mock.module("../mail/send", () => ({ + sendMail: vi.fn().mockResolvedValue({ messageId: null, providerResponse: null }), +})); + +const { db } = await import("../db/client"); +const { contacts, emailContentVersions, ongoingSequences, sequenceEmails, sequences } = + await import("../db/schema"); +const { sendMail } = await import("../mail/send"); +const { seedTeamAndContact, truncateAll } = await import("../test/db"); +type TestDb = import("../test/db").TestDb; +const { emailContent, seedOngoingSequence, seedSequence } = await import( + "../test/fixtures" +); +const { processOngoingSequence } = await import( + "../automation/process-ongoing-sequence" +); +const { lockBroadcast } = await import("../automation/queries"); +const { getSequenceBySequenceId, updateMailInSequence, updateSequence } = await import( + "./queries" +); +const { + discardEmailChanges, + listEmailVersions, + publishEmailChanges, + restoreEmailVersion, +} = await import("./publish"); + +const tdb = db as unknown as TestDb; +const mockedSendMail = sendMail as unknown as Mock; + +beforeEach(async () => { + await truncateAll(tdb); + mockedSendMail.mockClear(); +}); + +async function seedRunningSequence(status = "active") { + const { team, contact } = await seedTeamAndContact(tdb); + const { sequenceRow, emailRows } = await seedSequence(tdb, { + teamId: team.id, + status, + emails: [ + { emailId: "email_e1", subject: "Welcome", delayInMillis: 0 }, + { emailId: "email_e2", subject: "Day two", delayInMillis: 0 }, + ], + }); + return { team, contact, sequenceRow, emailRows }; +} + +async function addContact(teamId: string, email: string) { + const [row] = await tdb + .insert(contacts) + .values({ teamId, email, unsubscribeToken: crypto.randomUUID() }) + .returning(); + return row; +} + +/** Sends `contact` their next email and returns its subject and HTML. */ +async function sendNext(teamId: string, sequenceId: string, contactId: string) { + const [existing] = await tdb + .select() + .from(ongoingSequences) + .where(eq(ongoingSequences.contactId, contactId)); + const row = + existing ?? (await seedOngoingSequence(tdb, { teamId, sequenceId, contactId })); + await tdb + .update(ongoingSequences) + .set({ nextEmailScheduledTime: Date.now() - 1000 }) + .where(eq(ongoingSequences.id, row.id)); + mockedSendMail.mockClear(); + await processOngoingSequence(row.id); + expect(mockedSendMail).toHaveBeenCalledTimes(1); + const sent = mockedSendMail.mock.calls[0][0]; + return { subject: sent.subject, html: sent.html }; +} + +async function emailRow(id: string) { + const [row] = await tdb + .select() + .from(sequenceEmails) + .where(eq(sequenceEmails.id, id)); + return row; +} + +const editedContent = () => emailContent({ text: "Edited body" }); + +describe("saving an email of a started sequence", () => { + it("saves a draft and keeps sending the published version", async () => { + const { team, contact, sequenceRow, emailRows } = await seedRunningSequence(); + + const updated = await updateMailInSequence({ + teamId: team.id, + sequenceId: sequenceRow.sequenceId, + emailId: "email_e1", + subject: "Half-finished {{ broken", + content: editedContent(), + }); + + const email = updated!.emails.find((e) => e.emailId === "email_e1")!; + expect(email.subject).toBe("Welcome"); + expect(email.draftSubject).toBe("Half-finished {{ broken"); + expect(JSON.stringify(email.draftContent)).toContain("Edited body"); + + const sent = await sendNext(team.id, sequenceRow.id, contact.id); + expect(sent.subject).toBe("Welcome"); + expect(sent.html).not.toContain("Edited body"); + expect((await emailRow(emailRows[0].id)).draftSubject).toBe( + "Half-finished {{ broken", + ); + }); + + it("drops a draft that matches the published version again", async () => { + const { team, sequenceRow, emailRows } = await seedRunningSequence(); + const args = { + teamId: team.id, + sequenceId: sequenceRow.sequenceId, + emailId: "email_e1", + }; + await updateMailInSequence({ ...args, subject: "Changed" }); + expect((await emailRow(emailRows[0].id)).draftSubject).toBe("Changed"); + + await updateMailInSequence({ ...args, subject: "Welcome" }); + const row = await emailRow(emailRows[0].id); + expect(row.draftSubject).toBeNull(); + expect(row.draftContent).toBeNull(); + }); + + it("applies delay and step changes right away", async () => { + const { team, sequenceRow, emailRows } = await seedRunningSequence(); + await updateMailInSequence({ + teamId: team.id, + sequenceId: sequenceRow.sequenceId, + emailId: "email_e2", + delayInMillis: 5_000, + published: false, + }); + const row = await emailRow(emailRows[1].id); + expect(row.delayInMillis).toBe(5_000); + expect(row.published).toBe(false); + expect(row.draftSubject).toBeNull(); + }); + + it("saves directly while paused, folding in an open draft", async () => { + const { team, sequenceRow, emailRows } = await seedRunningSequence(); + const args = { + teamId: team.id, + sequenceId: sequenceRow.sequenceId, + emailId: "email_e1", + }; + await updateMailInSequence({ ...args, content: editedContent() }); + await tdb + .update(sequences) + .set({ status: "paused" }) + .where(eq(sequences.id, sequenceRow.id)); + + await updateMailInSequence({ ...args, subject: "Paused edit" }); + const row = await emailRow(emailRows[0].id); + expect(row.subject).toBe("Paused edit"); + expect(JSON.stringify(row.content)).toContain("Edited body"); + expect(row.draftSubject).toBeNull(); + }); + + it("saves directly before the sequence has started", async () => { + const { team, sequenceRow, emailRows } = await seedRunningSequence("draft"); + await updateMailInSequence({ + teamId: team.id, + sequenceId: sequenceRow.sequenceId, + emailId: "email_e1", + subject: "New subject", + }); + const row = await emailRow(emailRows[0].id); + expect(row.subject).toBe("New subject"); + expect(row.draftSubject).toBeNull(); + }); +}); + +describe("publishEmailChanges", () => { + it("makes the draft live and keeps the replaced version in the history", async () => { + const { team, contact, sequenceRow, emailRows } = await seedRunningSequence(); + const ids = { teamId: team.id, sequenceId: sequenceRow.sequenceId }; + await updateMailInSequence({ + ...ids, + emailId: "email_e1", + subject: "Welcome aboard", + content: editedContent(), + }); + + const published = await publishEmailChanges(ids); + const email = published!.emails.find((e) => e.emailId === "email_e1")!; + expect(email.subject).toBe("Welcome aboard"); + expect(email.draftSubject).toBeNull(); + + const sent = await sendNext(team.id, sequenceRow.id, contact.id); + expect(sent.subject).toBe("Welcome aboard"); + expect(sent.html).toContain("Edited body"); + + const versions = await listEmailVersions({ ...ids, emailId: "email_e1" }); + expect(versions!.map((v) => [v.subject, v.live])).toEqual([ + ["Welcome aboard", true], + ["Welcome", false], + ]); + // Publishing again with nothing new changes nothing. + await publishEmailChanges(ids); + expect((await emailRow(emailRows[0].id)).subject).toBe("Welcome aboard"); + }); + + it("only publishes the emails asked for", async () => { + const { team, sequenceRow, emailRows } = await seedRunningSequence(); + const ids = { teamId: team.id, sequenceId: sequenceRow.sequenceId }; + await updateMailInSequence({ ...ids, emailId: "email_e1", subject: "One" }); + await updateMailInSequence({ ...ids, emailId: "email_e2", subject: "Two" }); + + await publishEmailChanges({ ...ids, emailIds: ["email_e2"] }); + expect((await emailRow(emailRows[0].id)).draftSubject).toBe("One"); + expect((await emailRow(emailRows[1].id)).subject).toBe("Two"); + + await expect( + publishEmailChanges({ ...ids, emailIds: ["email_nope"] }), + ).rejects.toThrow("email_not_found"); + }); + + it("keeps in-flight contacts on their version for new entrants only", async () => { + const { team, contact, sequenceRow } = await seedRunningSequence(); + const ids = { teamId: team.id, sequenceId: sequenceRow.sequenceId }; + // Already in the sequence, and has had the first email. + await sendNext(team.id, sequenceRow.id, contact.id); + + await updateMailInSequence({ ...ids, emailId: "email_e2", subject: "Day two v2" }); + await publishEmailChanges({ ...ids, scope: "new_entrants" }); + + const inFlight = await sendNext(team.id, sequenceRow.id, contact.id); + expect(inFlight.subject).toBe("Day two"); + + // Someone who enters after the publish gets the new version. + const newcomer = await addContact(team.id, "newcomer@inbox.sendlit.app"); + await sendNext(team.id, sequenceRow.id, newcomer.id); + const fresh = await sendNext(team.id, sequenceRow.id, newcomer.id); + expect(fresh.subject).toBe("Day two v2"); + }); + + it("gives in-flight contacts the new version by default", async () => { + const { team, contact, sequenceRow } = await seedRunningSequence(); + const ids = { teamId: team.id, sequenceId: sequenceRow.sequenceId }; + await sendNext(team.id, sequenceRow.id, contact.id); + + await updateMailInSequence({ ...ids, emailId: "email_e2", subject: "Day two v2" }); + await publishEmailChanges({ ...ids, scope: "new_entrants" }); + await updateMailInSequence({ ...ids, emailId: "email_e2", subject: "Day two v3" }); + // A later publish to everyone lifts the earlier pin too. + await publishEmailChanges(ids); + + const [row] = await tdb + .select() + .from(ongoingSequences) + .where(eq(ongoingSequences.contactId, contact.id)); + expect(row.pinnedVersions).toBeNull(); + expect((await sendNext(team.id, sequenceRow.id, contact.id)).subject).toBe( + "Day two v3", + ); + }); +}); + +describe("discardEmailChanges", () => { + it("throws the draft away", async () => { + const { team, sequenceRow, emailRows } = await seedRunningSequence(); + const ids = { teamId: team.id, sequenceId: sequenceRow.sequenceId }; + await updateMailInSequence({ ...ids, emailId: "email_e1", subject: "Oops" }); + + const after = await discardEmailChanges({ ...ids, emailIds: ["email_e1"] }); + expect(after!.emails.every((e) => e.draftSubject === null)).toBe(true); + expect((await emailRow(emailRows[0].id)).subject).toBe("Welcome"); + }); +}); + +describe("restoreEmailVersion", () => { + it("rolls a publish back through the draft", async () => { + const { team, contact, sequenceRow } = await seedRunningSequence(); + const ids = { teamId: team.id, sequenceId: sequenceRow.sequenceId }; + await updateMailInSequence({ ...ids, emailId: "email_e1", subject: "Bad edit" }); + await publishEmailChanges(ids); + + const versions = await listEmailVersions({ ...ids, emailId: "email_e1" }); + const previous = versions!.find((v) => !v.live)!; + const restored = await restoreEmailVersion({ + ...ids, + emailId: "email_e1", + versionId: previous.versionId, + }); + const email = restored!.emails.find((e) => e.emailId === "email_e1")!; + // Restored into the draft; still live until published. + expect(email.subject).toBe("Bad edit"); + expect(email.draftSubject).toBe("Welcome"); + + await publishEmailChanges(ids); + expect((await sendNext(team.id, sequenceRow.id, contact.id)).subject).toBe( + "Welcome", + ); + + await expect( + restoreEmailVersion({ ...ids, emailId: "email_e1", versionId: "ver_missing" }), + ).rejects.toThrow("version_not_found"); + }); +}); + +describe("a scheduled broadcast", () => { + async function seedScheduledBroadcast() { + const { team, contact } = await seedTeamAndContact(tdb); + const { sequenceRow, emailRows } = await seedSequence(tdb, { + teamId: team.id, + type: "broadcast", + // Scheduled, not sent yet. + report: {}, + emails: [{ emailId: "email_b1", subject: "Launch", delayInMillis: 0 }], + }); + await tdb + .update(sequences) + .set({ title: "Launch" }) + .where(eq(sequences.id, sequenceRow.id)); + return { team, contact, sequenceRow, emailRows }; + } + + it("sends the published content while edits are unpublished", async () => { + const { team, contact, sequenceRow, emailRows } = await seedScheduledBroadcast(); + const ids = { teamId: team.id, sequenceId: sequenceRow.sequenceId }; + await updateSequence({ ...ids, title: "Launch (draft)" }); + await updateMailInSequence({ + ...ids, + emailId: "email_b1", + content: editedContent(), + }); + + const row = await emailRow(emailRows[0].id); + expect(row.subject).toBe("Launch"); + expect(row.draftSubject).toBe("Launch (draft)"); + // The title stays the subject it would be sent with. + const [broadcast] = await tdb + .select() + .from(sequences) + .where(eq(sequences.id, sequenceRow.id)); + expect(broadcast.title).toBe("Launch"); + + const sent = await sendNext(team.id, sequenceRow.id, contact.id); + expect(sent.subject).toBe("Launch"); + expect(sent.html).not.toContain("Edited body"); + }); + + it("takes the new title when its subject is published", async () => { + const { team, sequenceRow } = await seedScheduledBroadcast(); + const ids = { teamId: team.id, sequenceId: sequenceRow.sequenceId }; + await updateSequence({ ...ids, title: "Renamed" }); + expect( + (await getSequenceBySequenceId(team.id, sequenceRow.sequenceId))!.title, + ).toBe("Launch"); + + const published = await publishEmailChanges(ids); + expect(published!.title).toBe("Renamed"); + expect(published!.emails[0].subject).toBe("Renamed"); + }); + + it("drops unpublished changes when it starts sending", async () => { + const { team, sequenceRow, emailRows } = await seedScheduledBroadcast(); + const ids = { teamId: team.id, sequenceId: sequenceRow.sequenceId }; + await updateSequence({ ...ids, title: "Renamed" }); + + await lockBroadcast(sequenceRow.id, []); + + const row = await emailRow(emailRows[0].id); + expect(row.subject).toBe("Launch"); + expect(row.draftSubject).toBeNull(); + expect(row.draftContent).toBeNull(); + }); + + it("can't be published once it has started sending", async () => { + const { team, sequenceRow } = await seedScheduledBroadcast(); + const ids = { teamId: team.id, sequenceId: sequenceRow.sequenceId }; + await updateSequence({ ...ids, title: "Renamed" }); + await tdb + .update(sequences) + .set({ report: { broadcast: { lockedAt: Date.now(), sentAt: null } } }) + .where(eq(sequences.id, sequenceRow.id)); + + await expect(publishEmailChanges(ids)).rejects.toThrow("mail_already_sent"); + }); +}); + +describe("version history", () => { + it("lists published versions newest first", async () => { + const { team, sequenceRow } = await seedRunningSequence(); + const ids = { teamId: team.id, sequenceId: sequenceRow.sequenceId }; + for (const subject of ["Second", "Third"]) { + await updateMailInSequence({ ...ids, emailId: "email_e1", subject }); + await publishEmailChanges(ids); + } + const versions = await listEmailVersions({ ...ids, emailId: "email_e1" }); + expect(versions!.map((v) => v.subject)).toEqual(["Third", "Second", "Welcome"]); + }); + + it("lists the live version of an email published before history was kept", async () => { + const { team, sequenceRow } = await seedRunningSequence(); + const versions = await listEmailVersions({ + teamId: team.id, + sequenceId: sequenceRow.sequenceId, + emailId: "email_e1", + }); + expect(versions).toHaveLength(1); + expect(versions![0]).toMatchObject({ subject: "Welcome", live: true }); + const stored = await tdb.select().from(emailContentVersions); + expect(stored.every((row) => row.publishedAt !== null)).toBe(true); + }); +}); diff --git a/apps/api/src/sequences/publish.ts b/apps/api/src/sequences/publish.ts new file mode 100644 index 00000000..88fbdec4 --- /dev/null +++ b/apps/api/src/sequences/publish.ts @@ -0,0 +1,376 @@ +import type { Email as EmailContent } from "@sendlit/email-editor"; +import { and, desc, eq, isNotNull, isNull, sql } from "drizzle-orm"; +import { assertEmailContentAllowed } from "../abuse/content-scan"; +import { db } from "../db/client"; +import { + emailContentVersions, + ongoingSequences, + sequenceEmails, + sequences, +} from "../db/schema"; +import { syncEmailContentMediaReferences } from "../media/email-content"; +import { captureEvent } from "../observability"; +import { getOrCreateContentVersion } from "./content-versions"; +import { draftPatch, editsAreDrafts, hasDraft, noDraft } from "./drafts"; +import { + emailMediaResource, + getSequenceBySequenceId, + type HydratedSequence, + isBroadcastLocked, + type SequenceEmail, +} from "./queries"; + +/** + * Who gets published changes in a running sequence: `in_flight` (default) + * also gives them to contacts already in it at their next step; + * `new_entrants` keeps those contacts on the version they entered with. + */ +export const publishScopes = ["in_flight", "new_entrants"] as const; +export type PublishScope = (typeof publishScopes)[number]; + +/** The emails of `sequence` named by `emailIds` (all when omitted), or + * `email_not_found` for an id it doesn't have. */ +function selectEmails(sequence: HydratedSequence, emailIds?: string[]) { + if (!emailIds) return sequence.emails; + const byId = new Map(sequence.emails.map((email) => [email.emailId, email])); + return [...new Set(emailIds)].map((emailId) => { + const email = byId.get(emailId); + if (!email) throw new Error("email_not_found"); + return email; + }); +} + +/** Records that a version has been live, for one that predates version + * history: since it was first stored, which is when it was first sent. */ +async function markWasLive(tx: Pick, versionId: string) { + await tx + .update(emailContentVersions) + .set({ + publishedAt: sql`COALESCE(${emailContentVersions.publishedAt}, LEAST(${emailContentVersions.createdAt}, now() - interval '1 millisecond'))`, + }) + .where(eq(emailContentVersions.id, versionId)); +} + +/** + * Publishes the drafts of a broadcast's or sequence's emails (`emailIds`, + * or every email with a draft): later sends use them. Emails without a + * draft are left alone. Each published version is kept in the email's + * history (`listEmailVersions`), including the one it replaces, so a + * publish can be rolled back with `restoreEmailVersion`. + * + * With `scope: "new_entrants"`, contacts already in a sequence who haven't + * got the email yet keep getting the version they entered with. Published + * content passes the hosted content scan first, as when a sequence starts. + * Returns null when there is no such sequence. + */ +export async function publishEmailChanges({ + teamId, + sequenceId, + emailIds, + scope = "in_flight", +}: { + teamId: string; + sequenceId: string; + emailIds?: string[]; + scope?: PublishScope; +}): Promise { + const sequence = await getSequenceBySequenceId(teamId, sequenceId); + if (!sequence) return null; + // What a sent broadcast's recipients got can't change any more. + if (isBroadcastLocked(sequence)) throw new Error("mail_already_sent"); + const drafts = selectEmails(sequence, emailIds).filter(hasDraft); + if (drafts.length === 0) return sequence; + + for (const email of drafts) { + await assertEmailContentAllowed({ + teamId, + email, + subject: email.draftSubject!, + content: email.draftContent, + }); + } + + const now = new Date(); + // Only contacts in a sequence can be kept on an earlier version; a + // broadcast has none until it sends, and then it can't be edited. + const pinInFlight = scope === "new_entrants" && sequence.type === "sequence"; + for (const email of drafts) { + const [previous, next] = await Promise.all([ + getOrCreateContentVersion({ ...email, teamId }), + getOrCreateContentVersion({ + id: email.id, + teamId, + subject: email.draftSubject!, + content: email.draftContent, + }), + ]); + await db.transaction(async (tx) => { + // The version being replaced was live until now, so it's history. + // Timestamps come from the database, which also stamped `created_at`, + // so the replaced version always sorts before the new one. + await markWasLive(tx, previous.id); + await tx + .update(emailContentVersions) + .set({ publishedAt: sql`now()` }) + .where(eq(emailContentVersions.id, next.id)); + // A broadcast's title is its published subject. + if (sequence.type === "broadcast") { + await tx + .update(sequences) + .set({ title: email.draftSubject!, updatedAt: now }) + .where(eq(sequences.id, sequence.id)); + } + // Guarded on the draft read above, so an edit saved meanwhile stays a + // draft instead of going live unscanned. + const [published] = await tx + .update(sequenceEmails) + .set({ + subject: email.draftSubject!, + content: email.draftContent as any, + ...noDraft, + updatedAt: now, + }) + .where( + and( + eq(sequenceEmails.id, email.id), + email.draftUpdatedAt + ? eq(sequenceEmails.draftUpdatedAt, email.draftUpdatedAt) + : isNull(sequenceEmails.draftUpdatedAt), + ), + ) + .returning({ id: sequenceEmails.id }); + if (!published) throw new Error("draft_changed"); + + const emailKey = sql`${email.emailId}::text`; + await tx + .update(ongoingSequences) + .set({ + pinnedVersions: pinInFlight + ? // Keep contacts who haven't had it yet on what they'd get today; + // one already kept on an older version stays on that. + sql`COALESCE(${ongoingSequences.pinnedVersions}, '{}'::jsonb) || jsonb_build_object(${emailKey}, COALESCE(${ongoingSequences.pinnedVersions}->>${emailKey}, ${previous.id}::text))` + : // Everyone in the sequence gets the new version. + sql`NULLIF(${ongoingSequences.pinnedVersions} - ${emailKey}, '{}'::jsonb)`, + }) + .where( + and( + eq(ongoingSequences.sequenceId, sequence.id), + sql`NOT (${email.emailId} = ANY(${ongoingSequences.sentEmailIds}))`, + pinInFlight ? undefined : isNotNull(ongoingSequences.pinnedVersions), + ), + ); + }); + // The draft's images are the email's only ones now. + await syncEmailContentMediaReferences({ + teamId, + content: email.draftContent as EmailContent, + resource: emailMediaResource(sequence, email), + }); + } + + captureEvent({ + event: "sequence_changes_published", + source: "sequences.publish_changes", + teamId, + properties: { + sequence_id: sequence.sequenceId, + sequence_type: sequence.type, + sequence_status: sequence.status, + emails_count: drafts.length, + publish_scope: scope, + }, + }); + return getSequenceBySequenceId(teamId, sequenceId); +} + +/** + * Throws away the drafts of the given emails (all when omitted), so the + * editor shows the published version again. + */ +export async function discardEmailChanges({ + teamId, + sequenceId, + emailIds, +}: { + teamId: string; + sequenceId: string; + emailIds?: string[]; +}): Promise { + const sequence = await getSequenceBySequenceId(teamId, sequenceId); + if (!sequence) return null; + const drafts = selectEmails(sequence, emailIds).filter(hasDraft); + if (drafts.length === 0) return sequence; + + await db.transaction(async (tx) => { + for (const email of drafts) { + await tx + .update(sequenceEmails) + .set({ ...noDraft, updatedAt: new Date() }) + .where(eq(sequenceEmails.id, email.id)); + } + }); + // Images only the draft used are no longer referenced. + for (const email of drafts) { + await syncEmailContentMediaReferences({ + teamId, + content: email.content as EmailContent, + resource: emailMediaResource(sequence, email), + }); + } + + captureEvent({ + event: "sequence_changes_discarded", + source: "sequences.discard_changes", + teamId, + properties: { + sequence_id: sequence.sequenceId, + sequence_type: sequence.type, + emails_count: drafts.length, + }, + }); + return getSequenceBySequenceId(teamId, sequenceId); +} + +const VERSION_HISTORY_LIMIT = 20; + +export type PublishedEmailVersion = { + versionId: string; + subject: string; + content: unknown; + publishedAt: Date; + /** Whether this is the version sends use now. */ + live: boolean; +}; + +/** The versions an email has been published with, newest first (at most + * 20). The one it sends now is `live`; before its first publish there is + * no history yet and only the live version is listed. */ +export async function listEmailVersions({ + teamId, + sequenceId, + emailId, +}: { + teamId: string; + sequenceId: string; + emailId: string; +}): Promise { + const email = await findEmail(teamId, sequenceId, emailId); + if (!email) return null; + // The live version is always listed: one published before version + // history was kept is recorded now, so it can be restored later. + const live = await getOrCreateContentVersion({ ...email, teamId }); + await markWasLive(db, live.id); + const rows = await db + .select() + .from(emailContentVersions) + .where( + and( + eq(emailContentVersions.sequenceEmailId, email.id), + isNotNull(emailContentVersions.publishedAt), + ), + ) + .orderBy( + // Live first, then the rest by when they went live. + desc(eq(emailContentVersions.id, live.id)), + desc(emailContentVersions.publishedAt), + ) + .limit(VERSION_HISTORY_LIMIT); + return rows.map((row) => ({ + versionId: row.versionId, + subject: row.subject, + content: row.content, + publishedAt: row.publishedAt!, + live: row.id === live.id, + })); +} + +/** + * Loads an earlier published version (`versionId` from `listEmailVersions`) + * into the email's draft, to be published like any other change. Restoring + * the live version just discards the draft. Unless the broadcast or + * sequence is active, it's restored directly. + */ +export async function restoreEmailVersion({ + teamId, + sequenceId, + emailId, + versionId, +}: { + teamId: string; + sequenceId: string; + emailId: string; + versionId: string; +}): Promise { + const sequence = await getSequenceBySequenceId(teamId, sequenceId); + const email = sequence?.emails.find((item) => item.emailId === emailId); + if (!sequence || !email) return null; + if (isBroadcastLocked(sequence)) throw new Error("mail_already_sent"); + const [version] = await db + .select() + .from(emailContentVersions) + .where( + and( + eq(emailContentVersions.sequenceEmailId, email.id), + eq(emailContentVersions.versionId, versionId), + isNotNull(emailContentVersions.publishedAt), + ), + ) + .limit(1); + if (!version) throw new Error("version_not_found"); + + const restored = { subject: version.subject, content: version.content }; + await db.transaction(async (tx) => { + // Unless it's active, edits are saved directly. + await tx + .update(sequenceEmails) + .set({ + ...(editsAreDrafts(sequence) + ? draftPatch(email, restored) + : { ...restored, ...noDraft }), + updatedAt: new Date(), + }) + .where(eq(sequenceEmails.id, email.id)); + // A broadcast's title is its published subject: it follows a restore + // saved directly, and a drafted one when that's published. + if ( + sequence.type === "broadcast" && + !editsAreDrafts(sequence) && + sequence.title !== version.subject + ) { + await tx + .update(sequences) + .set({ title: version.subject, updatedAt: new Date() }) + .where(eq(sequences.id, sequence.id)); + } + }); + await syncEmailContentMediaReferences({ + teamId, + content: version.content as EmailContent, + alsoReferenced: editsAreDrafts(sequence) + ? (email.content as EmailContent) + : undefined, + resource: emailMediaResource(sequence, email), + }); + + captureEvent({ + event: "sequence_email_version_restored", + source: "sequences.restore_version", + teamId, + properties: { + sequence_id: sequence.sequenceId, + sequence_type: sequence.type, + email_id: email.emailId, + version_id: version.versionId, + }, + }); + return getSequenceBySequenceId(teamId, sequenceId); +} + +async function findEmail( + teamId: string, + sequenceId: string, + emailId: string, +): Promise { + const sequence = await getSequenceBySequenceId(teamId, sequenceId); + return sequence?.emails.find((email) => email.emailId === emailId) ?? null; +} diff --git a/apps/api/src/sequences/queries.ts b/apps/api/src/sequences/queries.ts index 37cb9bd3..52528fd0 100644 --- a/apps/api/src/sequences/queries.ts +++ b/apps/api/src/sequences/queries.ts @@ -68,6 +68,7 @@ import { type SequenceDeleteBlocker, sequenceDeleteBlocker, } from "./delete-blockers"; +import { draftPatch, editsAreDrafts, noDraft, workingCopy } from "./drafts"; import { addRule, defaultEmailContent, @@ -179,13 +180,7 @@ export async function createSequence({ const reconciledContent = await syncEmailContentMediaReferences({ teamId, content, - resource: { - resourceType: "SEQUENCE_EMAIL", - resourceInternalId: email.id, - resourcePublicId: email.emailId, - parentResourceInternalId: sequence.id, - parentResourcePublicId: sequence.sequenceId, - }, + resource: emailMediaResource(sequence, email), }); if (reconciledContent) { await db @@ -357,7 +352,11 @@ export async function updateSequence({ throw new Error(responses.mail_already_sent); } const patch: Partial = { updatedAt: new Date() }; - if (title !== undefined) patch.title = title; + // A broadcast's title is its published subject: while it's scheduled, a + // new title is the email's draft subject and becomes the title when + // it's published, so a broadcast never shows a title it wasn't sent with. + const titleIsDraft = current.type === "broadcast" && editsAreDrafts(current); + if (title !== undefined && !titleIsDraft) patch.title = title; const triggerChanged = triggerType !== undefined || triggerData !== undefined || @@ -451,7 +450,18 @@ export async function updateSequence({ if (broadcastEmail) { await db .update(sequenceEmails) - .set({ subject: title, updatedAt: new Date() }) + .set({ + // A scheduled broadcast's new subject waits to be published. + ...(editsAreDrafts(current) + ? draftPatch(broadcastEmail, { subject: title }) + : { + subject: title, + // An open draft keeps its content, now saved directly. + content: workingCopy(broadcastEmail).content as any, + ...noDraft, + }), + updatedAt: new Date(), + }) .where(eq(sequenceEmails.id, broadcastEmail.id)); } } @@ -504,13 +514,7 @@ export async function addMailToSequence({ const reconciledContent = await syncEmailContentMediaReferences({ teamId, content, - resource: { - resourceType: "SEQUENCE_EMAIL", - resourceInternalId: email.id, - resourcePublicId: email.emailId, - parentResourceInternalId: sequence.id, - parentResourcePublicId: sequence.sequenceId, - }, + resource: emailMediaResource(sequence, email), }); if (reconciledContent) { await db @@ -543,7 +547,22 @@ export async function addMailToSequence({ return hydrate(row); } -function isBroadcastLocked(sequence: HydratedSequence): boolean { +export function emailMediaResource( + sequence: Pick, + email: Pick, +) { + return { + resourceType: "SEQUENCE_EMAIL" as const, + resourceInternalId: email.id, + resourcePublicId: email.emailId, + parentResourceInternalId: sequence.id, + parentResourcePublicId: sequence.sequenceId, + }; +} + +export function isBroadcastLocked( + sequence: Pick, +): boolean { const report = (sequence.report || {}) as any; return ( sequence.type === "broadcast" && @@ -587,19 +606,17 @@ export async function updateMailInSequence({ if (!template) throw new Error(responses.item_not_found); } + // While it's active, subject and content edits are saved as a draft and + // the published version keeps sending until they're published (#184). + const asDraft = editsAreDrafts(sequence); if (content) { verifyMandatoryTags((content as any).content || []); content = (await syncEmailContentMediaReferences({ teamId, content: content as EmailContent, - resource: { - resourceType: "SEQUENCE_EMAIL", - resourceInternalId: email.id, - resourcePublicId: email.emailId, - parentResourceInternalId: sequence.id, - parentResourcePublicId: sequence.sequenceId, - }, + alsoReferenced: asDraft ? (email.content as EmailContent) : undefined, + resource: emailMediaResource(sequence, email), })) || content; } @@ -607,21 +624,24 @@ export async function updateMailInSequence({ // Broadcasts always mirror the sequence title as their subject and are // always published, so those fields aren't independently editable. if (sequence.type === "broadcast") { - patch.subject = sequence.title; + // Its title is the published subject; a drafted subject (set through + // the title while scheduled) is kept by `draftPatch`. + subject = asDraft ? undefined : sequence.title; patch.published = true; - } else { - if (subject !== undefined) patch.subject = subject; - if (published !== undefined) patch.published = published; + } else if (published !== undefined) { + patch.published = published; } - if (content !== undefined) patch.content = content as any; - // A running sequence sends edits straight away, so they're scanned now. - if (sequence.status === "active" && (patch.content || patch.subject)) { - await assertEmailContentAllowed({ - teamId, - email, - subject: patch.subject ?? email.subject, - content: patch.content ?? email.content, - }); + if (asDraft) { + if (subject !== undefined || content !== undefined) { + Object.assign(patch, draftPatch(email, { subject, content })); + } + } else if (subject !== undefined || content !== undefined) { + // Saved directly; an open draft (from while it was active) is what the + // editor showed, so it's folded in. + const working = workingCopy(email); + patch.subject = subject ?? working.subject; + patch.content = (content ?? working.content) as any; + Object.assign(patch, noDraft); } if (delayInMillis !== undefined) patch.delayInMillis = delayInMillis; if (templateId !== undefined) patch.templateId = templateId; diff --git a/apps/api/src/sequences/routes-purpose.test.ts b/apps/api/src/sequences/routes-purpose.test.ts index ecb44c33..c0d1068c 100644 --- a/apps/api/src/sequences/routes-purpose.test.ts +++ b/apps/api/src/sequences/routes-purpose.test.ts @@ -32,6 +32,12 @@ mock.module("../web/queries", () => ({ updateWebPublishing: mock(), WebPublishingError: class extends Error {}, })); +mock.module("./publish", () => ({ + publishEmailChanges: mock(), + discardEmailChanges: mock(), + listEmailVersions: mock(), + restoreEmailVersion: mock(), +})); mock.module("./test-send", () => ({ sendSequenceEmailTest: mocks.sendSequenceEmailTest, })); diff --git a/apps/api/src/sequences/routes.ts b/apps/api/src/sequences/routes.ts index 76d71f9e..ebca5d1d 100644 --- a/apps/api/src/sequences/routes.ts +++ b/apps/api/src/sequences/routes.ts @@ -16,6 +16,13 @@ import { WebPublishingError, } from "../web/queries"; import { broadcastArchiveErrors, isSequenceDeleteBlocker } from "./delete-blockers"; +import { sequenceHasUnpublishedChanges, toPublicSequenceEmail } from "./drafts"; +import { + discardEmailChanges, + listEmailVersions, + publishEmailChanges, + restoreEmailVersion, +} from "./publish"; import { addMailToSequence, countSequences, @@ -69,12 +76,24 @@ function toBody(sequence: HydratedSequence): any { "webNoindex", "webCanonicalUrl", ]), - emails: sequence.emails.map((email) => - omitInternal(email, ["sequenceId", "contentScanHash", "contentScanReasons"]), - ), + emails: sequence.emails.map(toPublicSequenceEmail), + hasUnpublishedChanges: sequenceHasUnpublishedChanges(sequence), }); } +/** The draft and publish operations' known failures: 404 for an email or + * version the sequence doesn't have, 409 when a publish can't go ahead. */ +function draftError(err: unknown) { + const error = err instanceof Error ? err.message : ""; + if (error === "email_not_found" || error === "version_not_found") { + return { status: 404 as const, body: { error } }; + } + if (error === "mail_already_sent" || error === "draft_changed") { + return { status: 409 as const, body: { error } }; + } + return null; +} + const impl = s.router(contract.sequences, { create: async ({ body, req }) => { try { @@ -170,6 +189,61 @@ const impl = s.router(contract.sequences, { return { status: 400, body: { error: err.message } }; } }, + publishChanges: async ({ params, body, req }) => { + try { + const sequence = await publishEmailChanges({ + teamId: (req as any).teamId, + sequenceId: params.sequenceId, + emailIds: body.emailIds, + scope: body.scope, + }); + if (!sequence) return { status: 404, body: { error: "Sequence not found" } }; + return { status: 200, body: toBody(sequence) }; + } catch (err: any) { + // Content checks (`content_blocked`, `content_held_for_review`). + return draftError(err) ?? { status: 400, body: { error: err.message } }; + } + }, + discardChanges: async ({ params, body, req }) => { + try { + const sequence = await discardEmailChanges({ + teamId: (req as any).teamId, + sequenceId: params.sequenceId, + emailIds: body.emailIds, + }); + if (!sequence) return { status: 404, body: { error: "Sequence not found" } }; + return { status: 200, body: toBody(sequence) }; + } catch (err) { + const known = draftError(err); + if (known?.status === 404) return known; + throw err; + } + }, + emailVersions: async ({ params, req }) => { + const versions = await listEmailVersions({ + teamId: (req as any).teamId, + sequenceId: params.sequenceId, + emailId: params.emailId, + }); + if (!versions) return { status: 404, body: { error: "Email not found" } }; + return { status: 200, body: { items: serializeDates(versions) as any[] } }; + }, + restoreEmailVersion: async ({ params, req }) => { + try { + const sequence = await restoreEmailVersion({ + teamId: (req as any).teamId, + sequenceId: params.sequenceId, + emailId: params.emailId, + versionId: params.versionId, + }); + if (!sequence) return { status: 404, body: { error: "Email not found" } }; + return { status: 200, body: toBody(sequence) }; + } catch (err) { + const known = draftError(err); + if (known) return known; + throw err; + } + }, sendEmailTest: async ({ params, body, req }) => { try { const result = await sendSequenceEmailTest({ diff --git a/apps/api/src/sequences/test-send.test.ts b/apps/api/src/sequences/test-send.test.ts index 266c58bf..0eda815b 100644 --- a/apps/api/src/sequences/test-send.test.ts +++ b/apps/api/src/sequences/test-send.test.ts @@ -82,6 +82,29 @@ describe("sendSequenceEmailTest", () => { expect(result.lastTestSentAt).toBe(row.lastTestSentAt?.toISOString() ?? null); }); + it("sends the unpublished draft, so it can be checked before publishing", async () => { + const { team, sequenceRow, email } = await seed(); + await tdb + .update(sequenceEmails) + .set({ + draftSubject: "Hello again", + draftContent: emailContent({ text: "Draft body" }), + draftUpdatedAt: new Date(), + }) + .where(eq(sequenceEmails.id, email.id)); + + await sendSequenceEmailTest({ + teamId: team.id, + sequenceId: sequenceRow.sequenceId, + emailId: email.emailId, + to: ["me@example.com"], + }); + + const sent = mockedSendMail.mock.calls[0][0]; + expect(sent.subject).toBe("[Test] Hello again"); + expect(sent.html).toContain("Draft body"); + }); + it("links a web version of the email as tested, not later edits", async () => { const { team, sequenceRow, email } = await seed(); diff --git a/apps/api/src/sequences/test-send.ts b/apps/api/src/sequences/test-send.ts index d2a25a21..6cb01251 100644 --- a/apps/api/src/sequences/test-send.ts +++ b/apps/api/src/sequences/test-send.ts @@ -30,6 +30,7 @@ import { getTeam } from "../team/queries"; import { normalizeEmail } from "../utils/email"; import { getEmailFrom, getListUnsubscribeHeaders } from "../utils/mail"; import { getOrCreateContentVersion } from "./content-versions"; +import { workingCopy } from "./drafts"; import { takeTestSendQuota } from "./test-send-limits"; /** A test goes to at most this many addresses (Mailchimp allows a handful). */ @@ -76,7 +77,7 @@ export async function sendSequenceEmailTest({ .where(and(eq(sequences.teamId, teamId), eq(sequences.sequenceId, sequenceId))) .limit(1); if (!sequence) throw new Error("sequence_not_found"); - const [email] = await db + const [stored] = await db .select() .from(sequenceEmails) .where( @@ -86,7 +87,9 @@ export async function sendSequenceEmailTest({ ), ) .limit(1); - if (!email) throw new Error("email_not_found"); + if (!stored) throw new Error("email_not_found"); + // A test shows the edits being worked on: the draft when there is one. + const email = { ...stored, ...workingCopy(stored) }; const team = await getTeam(teamId); if (!team) throw new Error("sequence_not_found"); diff --git a/apps/docs/content/docs/developers/mcp.mdx b/apps/docs/content/docs/developers/mcp.mdx index cc80192e..c60a15da 100644 --- a/apps/docs/content/docs/developers/mcp.mdx +++ b/apps/docs/content/docs/developers/mcp.mdx @@ -11,6 +11,9 @@ https://api.sendlit.app/mcp For a local installation, replace the origin with the API origin configured in your environment, for example `http://localhost:5000/mcp`. +In the dashboard, **Settings → AI assistants** shows your server URL with setup +steps for Claude, Claude Code, Cursor, VS Code and other clients. + ## Connect and authenticate SendLit supports both the current `2026-07-28` protocol and the existing `2025-11-25` Streamable HTTP initialize handshake. Modern clients may call `server/discover`, then `tools/list` and `tools/call`; existing clients begin with `initialize`. Every request is self-contained: there is no MCP session ID or retained server-side transport state. @@ -131,13 +134,14 @@ Broadcasts use the sequence resource with one email and an audience filter. For - `list_sequences`, `get_sequence`, `create_sequence`, `update_sequence` - `add_sequence_email`, `update_sequence_email`, `delete_sequence_email` +- `publish_sequence_changes`, `discard_sequence_changes`, `list_sequence_email_versions`, `restore_sequence_email_version`: while a broadcast or sequence is active, `update_sequence_email` saves subject and content changes as a draft, and they reach contacts only after `publish_sequence_changes` - `preview_sequence_deletion`, `delete_sequence` (destructive: pause an active sequence first; its enrollments and stats are deleted). Draft and scheduled broadcasts can be deleted; sent ones can't (`broadcast_sent`) - `archive_broadcast`, `unarchive_broadcast` hide a sent broadcast from the list and the web archive, keeping its report. `list_sequences` takes `archived` (`exclude` by default, `only`, `include`) - `send_sequence_email_test` sends a `[Test]` copy of one email to you (or up to five addresses) - `start_sequence`, `pause_sequence`, `get_sequence_stats`, `get_sequence_schedule`, `list_sequence_email_waiting_contacts`, `get_sequence_subscribers` - `get_broadcast_web_publishing`, `update_broadcast_web_publishing`, `list_web_issues`, `get_web_issue` ([web archive](/email-marketing/web-archive)) -Send a test with `send_sequence_email_test` and use `start_sequence` only after the audience, trigger, email content, delays, and optional tag actions are correct. Use `pause_sequence` to stop future sequence processing. +Send a test with `send_sequence_email_test` and use `start_sequence` only after the audience, trigger, email content, delays, and optional tag actions are correct. Use `pause_sequence` to stop future sequence processing. To change a running sequence, edit with `update_sequence_email`, send a test (it uses the draft), and confirm with the user before `publish_sequence_changes`. ### Transactional email diff --git a/apps/docs/content/docs/developers/sdk.mdx b/apps/docs/content/docs/developers/sdk.mdx index 91aa7e55..72746f52 100644 --- a/apps/docs/content/docs/developers/sdk.mdx +++ b/apps/docs/content/docs/developers/sdk.mdx @@ -167,3 +167,8 @@ await sendlit.sequences.start(broadcast.sequenceId); Starting a broadcast or sequence requires a mailing address in the team's settings and a way to send email: an organization delivery grant or the team's own ESP. + +While a broadcast or sequence is active, `updateEmail` saves subject and +content changes as a draft, and contacts keep getting the published version +until you call `sendlit.sequences.publishChanges(sequenceId)`. See +[Edit a running sequence](/email-marketing/sequences#edit-a-running-sequence). diff --git a/apps/docs/content/docs/email-marketing/broadcasts.mdx b/apps/docs/content/docs/email-marketing/broadcasts.mdx index f3f403fa..48a2bb7d 100644 --- a/apps/docs/content/docs/email-marketing/broadcasts.mdx +++ b/apps/docs/content/docs/email-marketing/broadcasts.mdx @@ -16,6 +16,22 @@ Marketing templates contain one final managed footer. SendLit renders the workspace mailing address and recipient-specific unsubscribe URL at delivery time. +## Edit a scheduled broadcast + +Once a broadcast is scheduled, changes to its subject and content are saved +as a draft and the broadcast keeps the last published version. Until you +click **Publish changes**, the **Content** section shows **Unpublished +changes** and warns that the broadcast will send the published version at its +send time. **Discard** throws the draft away, and **Version history**, in the +**⋯** menu next to **Edit content**, restores an earlier published version +into the draft. Its title stays the published subject until you publish a new one. +When the broadcast starts sending, any unpublished changes are dropped, and a +broadcast that has started sending can't be changed. Cancelling the +schedule makes it a draft again, and edits are saved directly. See +[Edit a running sequence](/email-marketing/sequences#edit-a-running-sequence) +for the API and MCP calls; a broadcast has no contacts in flight, so +publishing it takes no `scope`. + ## Plain text and the web version Every broadcast and sequence email is sent with a plain-text part generated diff --git a/apps/docs/content/docs/email-marketing/sequences.mdx b/apps/docs/content/docs/email-marketing/sequences.mdx index ff45140c..6df4579c 100644 --- a/apps/docs/content/docs/email-marketing/sequences.mdx +++ b/apps/docs/content/docs/email-marketing/sequences.mdx @@ -134,6 +134,49 @@ replayed when it is activated. Sequences pause themselves on high bounce or complaint rates like [broadcasts](/email-marketing/broadcasts#automatic-pause-on-bounces-and-complaints). +## Edit a running sequence + +While a sequence is active, changes to an email's subject and +content are saved as a draft. Contacts keep getting the published version, +so a half-finished edit, a typo or a broken merge tag never reaches anyone. +The sequences list, and each step with changes, shows **Unpublished +changes**. +**Send test** sends the draft, so you can check it first. Changes to a +step's delay, its **Send this email** switch and its tag action apply right +away. +While a sequence is a draft or paused, nothing is sending, so edits are saved +directly, including any unpublished changes left from while it was active. + +When the changes are ready, click **Publish changes** above the step's +content, or in the email editor. When several steps have changes, **Publish +all changes** at the top of the sequence page publishes them together. Then +choose who gets them: + +- **Contacts already in the sequence too** (the default): contacts get the + new version at their next step. +- **New entrants only**: contacts already in the sequence keep the version + they entered with; only contacts who enter from now on get the new one. + +**Discard** throws the draft away and shows the published version again. + +### Restore an earlier version + +Every version you publish is kept. Choose **Version history** in the **⋯** +menu next to a step's **Edit content** to see them, newest first, and +**Restore** one to load it into the draft. Publish it to roll back. + +### Over the API and MCP + +`PATCH /sequences/{sequenceId}/emails/{emailId}` saves to the draft; the +email's `draft` holds it and `hasUnpublishedChanges` is set on the email and +the sequence. Publish with `POST /sequences/{sequenceId}/publish-changes` +(optional `emailIds` and `scope`: `in_flight` or `new_entrants`), discard +with `POST /sequences/{sequenceId}/discard-changes`, list versions with +`GET /sequences/{sequenceId}/emails/{emailId}/versions` and restore one with +`POST .../versions/{versionId}/restore`. The MCP tools are +`publish_sequence_changes`, `discard_sequence_changes`, +`list_sequence_email_versions` and `restore_sequence_email_version`. + ## Delete a sequence Delete a sequence from the **⋯** menu on the Sequences list, or with diff --git a/apps/web/app/(dashboard)/broadcasts/[sequenceId]/page.tsx b/apps/web/app/(dashboard)/broadcasts/[sequenceId]/page.tsx index 28d7a875..8b56c488 100644 --- a/apps/web/app/(dashboard)/broadcasts/[sequenceId]/page.tsx +++ b/apps/web/app/(dashboard)/broadcasts/[sequenceId]/page.tsx @@ -29,6 +29,10 @@ import { useSetBreadcrumb } from "@/components/dashboard/breadcrumb-context"; import { BroadcastWebDialog } from "@/components/dashboard/broadcast-web-dialog"; import { DeleteSequenceDialog } from "@/components/dashboard/delete-sequence-dialog"; import { DeliverySourcePicker } from "@/components/dashboard/delivery-source-picker"; +import { + EmailMoreMenu, + UnpublishedChangesStrip, +} from "@/components/dashboard/draft-controls"; import { MARKETING_EMAIL_EDITOR_BLOCKS, marketingPreviewContext, @@ -107,6 +111,12 @@ function toLocalInputValue(date: Date): string { .slice(0, 16); } +/** A broadcast's title is its email's subject: the unpublished one while + * there is a draft. */ +function workingTitle(sequence: Sequence): string { + return sequence.emails[0]?.draft?.subject ?? sequence.title; +} + export default function BroadcastEditorPage({ params, }: { @@ -152,18 +162,12 @@ export default function BroadcastEditorPage({ const s = await getSequence(sequenceId); setSequence(s); setMeta({ - title: s.title, + title: workingTitle(s), filter: s.filter, excludeFilter: s.excludeFilter, deliverySource: s.deliverySource, }); - const firstEmail = s.emails[0]; - if (firstEmail) { - setEmail({ - content: firstEmail.content, - delayInMillis: firstEmail.delayInMillis, - }); - } + showEmail(s); if (s.status !== "draft") { setStats(await getSequenceStats(sequenceId)); } @@ -172,6 +176,28 @@ export default function BroadcastEditorPage({ } } + /** Shows the email's working copy: its unpublished changes when there + * are some. */ + function showEmail(s: Sequence) { + const firstEmail = s.emails[0]; + if (firstEmail) { + setEmail({ + content: firstEmail.draft?.content ?? firstEmail.content, + delayInMillis: firstEmail.delayInMillis, + }); + } + } + + /** A publish, discard or restore changed the email and maybe the title, + * which is its subject. */ + function onDraftsChanged(updated: Sequence) { + setSequence(updated); + showEmail(updated); + setMeta((current) => + current ? { ...current, title: workingTitle(updated) } : current, + ); + } + // biome-ignore lint/correctness/useExhaustiveDependencies: load is recreated every render; reload only when sequenceId changes useEffect(() => { load(); @@ -332,8 +358,12 @@ export default function BroadcastEditorPage({ // Except its content when the pause was because the email couldn't be built: // fixing it is how the broadcast gets going again. Its audience and schedule // stay locked. + // A scheduled broadcast's content can change too: edits are saved as + // unpublished changes until they're published. const contentEditable = - editable || (autoPaused && sequence.report?.autoPause?.reason === "render_error"); + editable || + scheduledFor !== null || + (autoPaused && sequence.report?.autoPause?.reason === "render_error"); return ( @@ -536,6 +566,12 @@ export default function BroadcastEditorPage({ } placeholder="e.g. October newsletter" /> + {workingTitle(sequence) !== sequence.title && ( +

+ Published title: {sequence.title || "—"}. The new title is part of + the unpublished changes. +

+ )}
@@ -597,15 +633,33 @@ export default function BroadcastEditorPage({ Content {contentEditable && ( - +
+ + {sequence.emails[0] && ( + + )} +
)}
- + + ) : contacts.length === 0 ? ( -

- No contacts yet. Create one to get started. -

+ filter.filters.length > 0 ? ( +

+ No contacts match these filters. +

+ ) : ( + + + + + + No contacts yet + + Import a CSV from another tool, add contacts one by one, or collect + them with a signup form. + + + +
+ load()} /> + +
+ +
+
+ ) ) : ( diff --git a/apps/web/app/(dashboard)/forms/page.tsx b/apps/web/app/(dashboard)/forms/page.tsx index ed0c81b0..50235e77 100644 --- a/apps/web/app/(dashboard)/forms/page.tsx +++ b/apps/web/app/(dashboard)/forms/page.tsx @@ -1,8 +1,9 @@ "use client"; -import { Plus } from "lucide-react"; +import { ClipboardList, Plus } from "lucide-react"; import Link from "next/link"; import { useEffect, useState } from "react"; +import { AiAssistantHint } from "@/components/dashboard/ai-assistant-promo"; import { Banner } from "@/components/dashboard/banner"; import { useSetBreadcrumb } from "@/components/dashboard/breadcrumb-context"; import { DeleteConfirmationDialog } from "@/components/dashboard/delete-confirmation-dialog"; @@ -25,6 +26,14 @@ import { DialogTitle, } from "@/components/ui/codelit/dialog"; import { Textarea } from "@/components/ui/codelit/textarea"; +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@/components/ui/empty"; import { Table, TableBody, @@ -104,9 +113,25 @@ export default function SignupFormsPage() { {forms === null ? ( ) : forms.length === 0 ? ( -

- No signup forms yet. Create one to start collecting subscribers. -

+ + + + + + No signup forms yet + + Let people subscribe themselves from a hosted page or a form on your + website. + + + + + + + ) : (
diff --git a/apps/web/app/(dashboard)/layout.tsx b/apps/web/app/(dashboard)/layout.tsx index 9f7d9de4..918587b9 100644 --- a/apps/web/app/(dashboard)/layout.tsx +++ b/apps/web/app/(dashboard)/layout.tsx @@ -1,9 +1,10 @@ import { AppSidebar } from "@/components/dashboard/app-sidebar"; import { BreadcrumbProvider } from "@/components/dashboard/breadcrumb-context"; import { DashboardBreadcrumb } from "@/components/dashboard/dashboard-breadcrumb"; +import { McpUrlProvider } from "@/components/dashboard/mcp-url"; import { Separator } from "@/components/ui/separator"; import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; -import { getStatusPageUrl } from "@/lib/config"; +import { getMcpUrl, getStatusPageUrl } from "@/lib/config"; import { requireAuth } from "@/lib/server-auth"; export default async function DashboardLayout({ @@ -14,24 +15,26 @@ export default async function DashboardLayout({ await requireAuth(); return ( - - + + + - - -
-
- - - -
-
-
{children}
-
-
-
+ + +
+
+ + + +
+
+
{children}
+
+
+
+ ); } diff --git a/apps/web/app/(dashboard)/sequences/[sequenceId]/page.tsx b/apps/web/app/(dashboard)/sequences/[sequenceId]/page.tsx index b5e5b038..158f042a 100644 --- a/apps/web/app/(dashboard)/sequences/[sequenceId]/page.tsx +++ b/apps/web/app/(dashboard)/sequences/[sequenceId]/page.tsx @@ -9,6 +9,7 @@ import { type ReentryMode, type Sequence, SequenceAnalytics, + type SequenceEmail, SequenceEmailList, type SequenceStats, TagEditor, @@ -22,6 +23,11 @@ import { Banner } from "@/components/dashboard/banner"; import { useSetBreadcrumb } from "@/components/dashboard/breadcrumb-context"; import { DeleteSequenceDialog } from "@/components/dashboard/delete-sequence-dialog"; import { DeliverySourcePicker } from "@/components/dashboard/delivery-source-picker"; +import { + EmailMoreMenu, + PublishAllChangesActions, + UnpublishedChangesStrip, +} from "@/components/dashboard/draft-controls"; import { MARKETING_EMAIL_EDITOR_BLOCKS, marketingPreviewContext, @@ -99,6 +105,19 @@ const STATUS_VARIANT: Record e.emailId === emailId); - setEmailDraft( - found - ? { - subject: found.subject, - content: found.content, - delayInMillis: found.delayInMillis, - published: found.published, - actionType: found.actionType, - actionData: found.actionData, - } - : null, - ); + setEmailDraft(found ? editableEmail(found) : null); } // Who waits for which email depends on the order and what's published, so @@ -261,6 +269,12 @@ export default function SequenceEditorPage({ } } + /** A publish, discard or restore changed the emails' working copies. */ + function onDraftsChanged(updated: Sequence) { + setSequence(updated); + selectEmail(updated, selectedEmailId ?? undefined); + } + async function toggleStatus() { if (!sequence) return; setError(null); @@ -308,6 +322,14 @@ export default function SequenceEditorPage({ ); if (!sequence || !meta) return ; + const selectedEmail = sequence.emails.find((e) => e.emailId === selectedEmailId); + // Save covers the fields above the content, which is edited on its own page. + const emailChanged = + selectedEmail !== undefined && + emailDraft !== null && + JSON.stringify({ ...editableEmail(selectedEmail), content: null }) !== + JSON.stringify({ ...emailDraft, content: null }); + return (
@@ -318,7 +340,8 @@ export default function SequenceEditorPage({ {sequence.status}
-
+
+ -
-
- - - setEmailDraft({ - ...emailDraft, - subject: e.target.value, - }) - } - placeholder="Your subject line" - /> -
-
- - - setEmailDraft({ - ...emailDraft, - published, - }) - } - /> -
+
+ + + setEmailDraft({ + ...emailDraft, + subject: e.target.value, + }) + } + placeholder="Your subject line" + /> + {selectedEmail?.draft && + selectedEmail.draft.subject !== selectedEmail.subject && ( +

+ Published subject: {selectedEmail.subject || "—"} +

+ )}
-
+
-
-
+
+
+ + setEmailDraft({ + ...emailDraft, + published, + }) + } + /> + +
+ {/* Green only when there's something to save, so it doesn't + compete with Publish. */} + +
+ +
+
- +
+ + {selectedEmailId && ( + + )} +
+ {selectedEmailId && ( + + )} General Email service providers (ESP) API keys + AI assistants Web archive @@ -419,6 +421,10 @@ export default function SettingsPage() {
+ + + + diff --git a/apps/web/app/(dashboard)/templates/page.tsx b/apps/web/app/(dashboard)/templates/page.tsx index e216afe0..78c21b1d 100644 --- a/apps/web/app/(dashboard)/templates/page.tsx +++ b/apps/web/app/(dashboard)/templates/page.tsx @@ -6,9 +6,10 @@ import { type EmailTemplate, TemplateChooser, } from "@sendlit/email-blocks"; -import { Check, Copy, CopyPlus, Plus, Trash2 } from "lucide-react"; +import { Check, Copy, CopyPlus, Mail, Plus, Trash2 } from "lucide-react"; import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; +import { AiAssistantHint } from "@/components/dashboard/ai-assistant-promo"; import { Banner } from "@/components/dashboard/banner"; import { useSetBreadcrumb } from "@/components/dashboard/breadcrumb-context"; import { DeleteConfirmationDialog } from "@/components/dashboard/delete-confirmation-dialog"; @@ -35,6 +36,14 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/codelit/tooltip"; +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@/components/ui/empty"; import { deleteTemplate, duplicateTemplate, @@ -143,9 +152,32 @@ export default function TemplatesPage() { ) : visibleTemplates.length === 0 ? ( - - No templates yet. Create one for campaigns or API-triggered email. - + + + + + + + {purposeFilter === "all" + ? "No templates yet" + : `No ${purposeFilter} templates yet`} + + + {purposeFilter === "transactional" + ? "Reusable content for email your app sends through the API, like receipts and password resets." + : purposeFilter === "marketing" + ? "Reusable content to start broadcasts and sequences from." + : "Reusable content for broadcasts and sequences, or for email your app sends through the API."} + + + + + + + ) : (
diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 92693a72..b0d59a14 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -1,34 +1,37 @@ import { AppSidebar } from "@/components/dashboard/app-sidebar"; import { BreadcrumbProvider } from "@/components/dashboard/breadcrumb-context"; import { DashboardBreadcrumb } from "@/components/dashboard/dashboard-breadcrumb"; +import { McpUrlProvider } from "@/components/dashboard/mcp-url"; import { OverviewDashboard } from "@/components/dashboard/overview"; import { Separator } from "@/components/ui/separator"; import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; -import { getStatusPageUrl } from "@/lib/config"; +import { getMcpUrl, getStatusPageUrl } from "@/lib/config"; import { requireAuth } from "@/lib/server-auth"; export default async function Home() { await requireAuth(); return ( - - - - -
-
- - - + + + + + +
+
+ + + +
+
+
+
-
-
- -
-
-
-
+ + + + ); } diff --git a/apps/web/components/dashboard/ai-assistant-promo.tsx b/apps/web/components/dashboard/ai-assistant-promo.tsx new file mode 100644 index 00000000..dc84ca3e --- /dev/null +++ b/apps/web/components/dashboard/ai-assistant-promo.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { ArrowRight, Bot, X } from "lucide-react"; +import Link from "next/link"; +import { useEffect, useState } from "react"; +import { AI_ASSISTANTS_SETTINGS_HREF, useMcpUrl } from "@/components/dashboard/mcp-url"; +import { Button } from "@/components/ui/codelit/button"; +import { Card, CardContent } from "@/components/ui/codelit/card"; +import { IconButton } from "@/components/ui/codelit/icon-button"; + +const DISMISSED_KEY = "sendlit:ai-assistant-promo-dismissed"; + +/** + * Overview card that introduces the MCP server: what an assistant can do, + * and a link to set one up. Dismissed per browser. + */ +export function AiAssistantPromoCard() { + const url = useMcpUrl(); + // Hidden until we know it wasn't dismissed, so it never flashes. + const [visible, setVisible] = useState(false); + + useEffect(() => { + setVisible(localStorage.getItem(DISMISSED_KEY) === null); + }, []); + + if (!url || !visible) return null; + + return ( + + + + + +
+

Use SendLit from your AI assistant

+

+ Connect Claude, Cursor or VS Code and ask, for example, “Draft a + three-email welcome sequence for new signups” or “How did last + week's broadcast do?” +

+
+
+ + { + localStorage.setItem(DISMISSED_KEY, new Date().toISOString()); + setVisible(false); + }} + > + + +
+
+
+ ); +} + +/** One quiet line for an empty list whose items an assistant can create + * over MCP: "or have your AI assistant …". */ +export function AiAssistantHint({ action = "draft one" }: { action?: string }) { + if (!useMcpUrl()) return null; + return ( +

+ or{" "} + + have your AI assistant {action} + +

+ ); +} diff --git a/apps/web/components/dashboard/ai-assistant-settings.tsx b/apps/web/components/dashboard/ai-assistant-settings.tsx new file mode 100644 index 00000000..a820eeb9 --- /dev/null +++ b/apps/web/components/dashboard/ai-assistant-settings.tsx @@ -0,0 +1,223 @@ +"use client"; + +import { Bot, ExternalLink, KeyRound, ShieldCheck, Terminal } from "lucide-react"; +import Link from "next/link"; +import { type ReactNode, useState } from "react"; +import { Banner } from "@/components/dashboard/banner"; +import { DialogSection } from "@/components/dashboard/dialog-section"; +import { CopyButton } from "@/components/dashboard/domain-authentication-dialog"; +import { MCP_DOCS_URL, useMcpUrl } from "@/components/dashboard/mcp-url"; +import { Badge } from "@/components/ui/codelit/badge"; +import { Card, CardContent } from "@/components/ui/codelit/card"; + +/** True when clients on the internet can't reach `url`, e.g. a local API. */ +function isPrivateUrl(url: string): boolean { + try { + const { protocol, hostname } = new URL(url); + return ( + protocol !== "https:" || + hostname === "localhost" || + hostname.endsWith(".localhost") || + /^(127\.|10\.|192\.168\.)/.test(hostname) + ); + } catch { + return true; + } +} + +/** A value to paste somewhere: monospace, with a copy button. */ +function CopyBlock({ value, label }: { value: string; label: string }) { + return ( +
+
+        {value}
+      
+ +
+ ); +} + +interface Client { + id: string; + name: string; + summary: string; + icon: ReactNode; + body: (url: string, isPrivate: boolean) => ReactNode; +} + +const CLIENTS: Client[] = [ + { + id: "claude", + name: "Claude", + summary: "claude.ai, and the Claude desktop and mobile apps", + icon: , + body: (url, isPrivate) => ( + <> +

+ In Claude, open Settings → Connectors, choose{" "} + Add custom connector, and paste the server URL. Claude opens + SendLit's sign-in page the first time you use it. +

+ + {isPrivate && ( + + Claude connects from the internet, so it can't reach this server at a + local or non-HTTPS address. Use Claude Code or another client on this + machine instead. + + )} + + ), + }, + { + id: "claude-code", + name: "Claude Code", + summary: "In your terminal", + icon: , + body: (url) => ( + <> +

+ Run this, then run /mcp in Claude Code to + sign in. +

+ + + ), + }, + { + id: "cursor", + name: "Cursor", + summary: "Add it to your MCP settings", + icon: , + body: (url) => ( + <> +

+ Add this to ~/.cursor/mcp.json (or a + project's .cursor/mcp.json), then sign + in from Cursor's MCP settings. +

+ + + ), + }, + { + id: "vscode", + name: "VS Code", + summary: "GitHub Copilot agent mode", + icon: , + body: (url) => ( + <> +

+ Add this to .vscode/mcp.json in your + workspace, then start the server from that file and sign in. +

+ + + ), + }, + { + id: "other", + name: "Another assistant", + summary: "Any client that supports remote MCP servers", + icon: , + body: (url) => ( + <> +

+ Add a remote MCP server (Streamable HTTP) with this URL. Clients that support + OAuth sign you in; for one that doesn't, send a team API key in the{" "} + x-sendlit-apikey header. +

+ + + Create an API key + + + ), + }, +]; + +/** + * Settings → AI assistants: how to connect an AI assistant to SendLit's MCP + * server, so it can work on contacts, emails and reports for you. + */ +export function AiAssistantSettings() { + const url = useMcpUrl(); + const [open, setOpen] = useState>({ claude: true }); + if (!url) return null; + const isPrivate = isPrivateUrl(url); + + return ( +
+
+

AI assistants

+

+ Connect Claude, Cursor, VS Code or another AI assistant to SendLit's MCP + server. Then ask it to import contacts, write a sequence, or explain how your + last broadcast did. +

+
+ + + +

MCP server URL

+ +

+ + + Your assistant signs in as you and works in the team you pick. Its edits + to running sequences and scheduled broadcasts are saved as drafts, so + nothing contacts receive changes until it's published, and SendLit + tells it to check with you first. + +

+
+
+ +
+ {CLIENTS.map((client) => ( + + setOpen((current) => ({ ...current, [client.id]: value })) + } + icon={client.icon} + iconClassName="bg-muted text-muted-foreground" + name={client.name} + summary={client.summary} + badge={ + client.id === "claude" && isPrivate ? ( + Needs a public URL + ) : null + } + > + {client.body(url, isPrivate)} + + ))} +
+ + + Tools, scopes and limits in the MCP docs + + +
+ ); +} diff --git a/apps/web/components/dashboard/dialog-section.tsx b/apps/web/components/dashboard/dialog-section.tsx new file mode 100644 index 00000000..36c36d4b --- /dev/null +++ b/apps/web/components/dashboard/dialog-section.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { ChevronDown } from "lucide-react"; +import type { ReactNode } from "react"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { cn } from "@/lib/utils"; + +/** One collapsible topic of a dialog body: status icon, name, one-line + * summary, status pill and chevron (see the dialog guidelines in AGENTS.md). */ +export function DialogSection({ + open, + onOpenChange, + icon, + iconClassName = "bg-[var(--warning-soft)] text-[var(--warning)]", + name, + summary, + badge, + children, +}: { + open: boolean; + onOpenChange?: (open: boolean) => void; + icon: ReactNode; + iconClassName?: string; + name: string; + summary: string; + badge: ReactNode; + children: ReactNode; +}) { + return ( + + + + {icon} + + + {name} + {summary} + + {badge} + + + + {children} + + + ); +} diff --git a/apps/web/components/dashboard/draft-controls.tsx b/apps/web/components/dashboard/draft-controls.tsx new file mode 100644 index 00000000..27a57c11 --- /dev/null +++ b/apps/web/components/dashboard/draft-controls.tsx @@ -0,0 +1,236 @@ +"use client"; + +import type { Sequence } from "@sendlit/email-blocks"; +import { CloudUpload, History, MoreHorizontal, Undo2 } from "lucide-react"; +import { type ReactNode, useState } from "react"; +import { toast } from "sonner"; +import { Banner } from "@/components/dashboard/banner"; +import { DeleteConfirmationDialog } from "@/components/dashboard/delete-confirmation-dialog"; +import { EmailVersionHistoryDialog } from "@/components/dashboard/email-version-history-dialog"; +import { + emailsWithChanges, + PublishChangesDialog, +} from "@/components/dashboard/publish-changes-dialog"; +import { Button } from "@/components/ui/codelit/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/codelit/dropdown-menu"; +import { IconButton } from "@/components/ui/codelit/icon-button"; +import { discardSequenceChanges } from "@/lib/api"; + +interface DraftTarget { + sequence: Sequence; + /** Some of its emails; all of them when unset. */ + emailIds?: string[]; + onChange: (sequence: Sequence) => void; +} + +/** The publish and discard dialogs, and functions that open them. */ +function useDraftDialogs({ + sequence, + emailIds, + onChange, + beforePublish, +}: DraftTarget & { + /** Runs before the publish dialog opens, e.g. to save the editor. */ + beforePublish?: () => Promise; +}) { + const [publishing, setPublishing] = useState(null); + const [discarding, setDiscarding] = useState(false); + const changed = emailsWithChanges(sequence, emailIds); + + const dialogs = ( + <> + !open && setPublishing(null)} + onPublished={onChange} + /> + { + onChange(await discardSequenceChanges(sequence.sequenceId, emailIds)); + setDiscarding(false); + toast.success("Unpublished changes discarded."); + }} + /> + + ); + + return { + changed, + /** A dialog is open, so the controls must stay mounted. */ + busy: publishing !== null || discarding, + openPublish: async () => setPublishing((await beforePublish?.()) ?? sequence), + openDiscard: () => setDiscarding(true), + dialogs, + }; +} + +/** + * The amber "Unpublished changes" strip, with Discard and Publish, shown above + * the content of a started broadcast or sequence while it has a draft. + */ +export function UnpublishedChangesStrip({ + message, + className, + ...target +}: DraftTarget & { + /** What happens if nothing is published; a default fits most places. */ + message?: ReactNode; + className?: string; +}) { + const draft = useDraftDialogs(target); + if (draft.changed.length === 0 && !draft.busy) return null; + + return ( + +

+ Unpublished changes + {" · "} + {message ?? + (target.sequence.type === "broadcast" + ? "The broadcast sends the published version until you publish." + : "Contacts get the published version until you publish.")} +

+
+ + +
+ {draft.dialogs} +
+ ); +} + +/** + * Publish and Discard for several emails at once: the page header of a + * sequence where more than one email has unpublished changes. One email's + * changes are published from its own strip. + */ +export function PublishAllChangesActions(target: DraftTarget) { + const draft = useDraftDialogs(target); + if (draft.changed.length < 2 && !draft.busy) return null; + + return ( + <> + + + {draft.dialogs} + + ); +} + +/** + * Publish for the full-screen editor's toolbar, with Discard and Version + * history in a "⋯" menu. Publish is secondary there: Save is the toolbar's + * primary action. + */ +export function EditorDraftActions({ + emailId, + beforePublish, + ...target +}: Omit & { + emailId: string; + beforePublish: () => Promise; +}) { + const draft = useDraftDialogs({ ...target, emailIds: [emailId], beforePublish }); + const hasDraft = draft.changed.length > 0; + + return ( + <> + {hasDraft && ( + + )} + + {draft.dialogs} + + ); +} + +/** + * The "⋯" menu of an email: its version history, once the broadcast or + * sequence has started (before that nothing has been published), and + * Discard when `onDiscard` is given. Renders nothing when it would be empty. + */ +export function EmailMoreMenu({ + sequence, + emailId, + onChange, + onDiscard, +}: Omit & { + emailId: string; + onDiscard?: () => void; +}) { + const [historyOpen, setHistoryOpen] = useState(false); + const hasHistory = sequence.status !== "draft"; + if (!hasHistory && !onDiscard) return null; + + return ( + <> + + + + + + + + {hasHistory && ( + setHistoryOpen(true)}> + + Version history + + )} + {onDiscard && ( + + + Discard changes + + )} + + + {hasHistory && ( + + )} + + ); +} diff --git a/apps/web/components/dashboard/email-version-history-dialog.tsx b/apps/web/components/dashboard/email-version-history-dialog.tsx new file mode 100644 index 00000000..f126dc1a --- /dev/null +++ b/apps/web/components/dashboard/email-version-history-dialog.tsx @@ -0,0 +1,187 @@ +"use client"; + +import { type Email, EmailPreview, type Sequence } from "@sendlit/email-blocks"; +import { Check, History, RotateCcw } from "lucide-react"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { Banner } from "@/components/dashboard/banner"; +import { DialogSection } from "@/components/dashboard/dialog-section"; +import { + MARKETING_EMAIL_EDITOR_BLOCKS, + marketingPreviewContext, +} from "@/components/dashboard/email-editor-screen"; +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 { + listSequenceEmailVersions, + restoreSequenceEmailVersion, + type SequenceEmailVersion, +} from "@/lib/api"; +import { ApiError } from "@/lib/api-client"; +import { relativeTime } from "@/lib/time"; + +/** + * The versions an email of a broadcast or sequence was published with. Restore + * loads one into the draft; publishing it rolls the email back. + */ +export function EmailVersionHistoryDialog({ + sequence, + emailId, + onOpenChange, + onRestored, +}: { + sequence: Sequence; + /** Open while set. */ + emailId: string | null; + onOpenChange: (open: boolean) => void; + onRestored: (sequence: Sequence) => void; +}) { + const [versions, setVersions] = useState(null); + const [error, setError] = useState(null); + const [restoring, setRestoring] = useState(null); + const [open, setOpen] = useState>({}); + + useEffect(() => { + if (!emailId) return; + setVersions(null); + setError(null); + listSequenceEmailVersions(sequence.sequenceId, emailId) + .then((items) => { + setVersions(items); + // The live version starts open; earlier ones fold. + setOpen(Object.fromEntries(items.map((v) => [v.versionId, v.live]))); + }) + .catch((err) => + setError(err instanceof ApiError ? err.message : "Failed to load the history"), + ); + }, [sequence.sequenceId, emailId]); + + const email = sequence.emails.find((e) => e.emailId === emailId); + const earlier = versions?.filter((version) => !version.live).length ?? 0; + + async function restore(versionId: string) { + if (!emailId) return; + setRestoring(versionId); + setError(null); + try { + const restored = await restoreSequenceEmailVersion( + sequence.sequenceId, + emailId, + versionId, + ); + onRestored(restored); + onOpenChange(false); + toast.success( + restored.status === "active" + ? "Version restored to the draft. Publish it to roll back." + : "Version restored.", + ); + } catch (err) { + setError(err instanceof ApiError ? err.message : "Failed to restore the version"); + } finally { + setRestoring(null); + } + } + + return ( + + + + Version history of {email?.subject || "this email"} + + Every version you publish is kept. Restore one to load it into the draft, + then publish it to roll back. + + {versions && ( +
+ + {earlier} earlier version{earlier === 1 ? "" : "s"} + + + {earlier === 0 ? " · publish a change to start the history" : ""} + +
+ )} +
+ +
+ {error && {error}} + {!versions && !error && } + {versions?.map((version) => ( + + setOpen((current) => ({ ...current, [version.versionId]: value })) + } + icon={ + version.live ? ( + + ) : ( + + ) + } + iconClassName={ + version.live + ? "bg-[var(--success-soft)] text-[#1f6b35]" + : "bg-muted text-muted-foreground" + } + name={version.subject || "Untitled email"} + summary={`Published ${relativeTime(version.publishedAt)}`} + badge={ + version.live ? ( + Live + ) : ( + Earlier + ) + } + > +

+ {version.live + ? "Contacts get this version now. Restoring it throws away the draft." + : "Restore loads this version into the draft. Nothing changes for contacts until you publish."} +

+ + {(!version.live || email?.draft) && ( + + )} +
+ ))} +
+ + +

+ Keeps the last 20 published versions. +

+ +
+
+
+ ); +} diff --git a/apps/web/components/dashboard/mcp-url.tsx b/apps/web/components/dashboard/mcp-url.tsx new file mode 100644 index 00000000..42feb40a --- /dev/null +++ b/apps/web/components/dashboard/mcp-url.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { createContext, type ReactNode, useContext } from "react"; + +const McpUrlContext = createContext(null); + +/** Hands the MCP server URL, a server-only setting (`getMcpUrl`), to client + * components, so it can change at container start without a rebuild. */ +export function McpUrlProvider({ + url, + children, +}: { + url: string; + children: ReactNode; +}) { + return {children}; +} + +/** The MCP server URL, or null outside an `McpUrlProvider`. */ +export function useMcpUrl(): string | null { + return useContext(McpUrlContext); +} + +/** Where the dashboard explains connecting an AI assistant. */ +export const AI_ASSISTANTS_SETTINGS_HREF = "/settings?tab=ai"; +export const MCP_DOCS_URL = "https://docs.sendlit.app/developers/mcp"; diff --git a/apps/web/components/dashboard/overview.tsx b/apps/web/components/dashboard/overview.tsx index 7be79f34..7fc96cd5 100644 --- a/apps/web/components/dashboard/overview.tsx +++ b/apps/web/components/dashboard/overview.tsx @@ -3,6 +3,7 @@ import { Activity, ArrowRight, Circle, Mail, Workflow } from "lucide-react"; import Link from "next/link"; import { useEffect, useState } from "react"; +import { AiAssistantPromoCard } from "@/components/dashboard/ai-assistant-promo"; import { useSetBreadcrumb } from "@/components/dashboard/breadcrumb-context"; import { ReputationCard } from "@/components/dashboard/reputation-card"; import { Card, CardContent } from "@/components/ui/card"; @@ -122,6 +123,7 @@ export function OverviewDashboard() { )} +
sequence.emails.find((email) => email.emailId === id)) + .filter( + (email): email is SequenceEmail => + Boolean(email?.draft) && (!emailIds || emailIds.includes(email!.emailId)), + ); +} + +/** What a draft changes, in words. */ +function changeSummary(email: SequenceEmail): string { + if (!email.draft) return ""; + const subject = email.draft.subject !== email.subject; + const content = JSON.stringify(email.draft.content) !== JSON.stringify(email.content); + if (subject && content) return "Subject and content changed"; + return subject ? "Subject changed" : "Content changed"; +} + +/** "Unpublished changes": amber with words, for a sequence, step or broadcast. */ +export function UnpublishedChangesBadge({ className }: { className?: string }) { + return ( + + Unpublished changes + + ); +} + +/** + * Publishes the unpublished changes of a broadcast or sequence (all + * of them, or `emailIds`). For a sequence it asks whether contacts already in + * it get the new version too. + */ +export function PublishChangesDialog({ + sequence, + emailIds, + onOpenChange, + onPublished, +}: { + /** Open while set. */ + sequence: Sequence | null; + emailIds?: string[]; + onOpenChange: (open: boolean) => void; + onPublished: (sequence: Sequence) => void; +}) { + const [scope, setScope] = useState("in_flight"); + const [schedule, setSchedule] = useState(null); + const [publishing, setPublishing] = useState(false); + const [error, setError] = useState(null); + const [open, setOpen] = useState>({}); + const [scopeOpen, setScopeOpen] = useState(true); + const pathname = usePathname(); + + const isSequence = sequence?.type === "sequence"; + const changed = sequence ? emailsWithChanges(sequence, emailIds) : []; + const changedKey = changed.map((email) => email.emailId).join(","); + + // biome-ignore lint/correctness/useExhaustiveDependencies: reset only when the dialog opens for another set of emails + useEffect(() => { + if (!sequence) return; + setScope("in_flight"); + setError(null); + setSchedule(null); + setScopeOpen(true); + // A short list starts open for review; a long one starts folded. + setOpen( + Object.fromEntries(changed.map((email) => [email.emailId, changed.length <= 2])), + ); + if (isSequence) { + getSequenceSchedule(sequence.sequenceId) + .then(setSchedule) + .catch(() => undefined); + } + }, [sequence?.sequenceId, changedKey]); + + if (!sequence) return null; + + // Contacts in the sequence who will still get a changed email: those + // waiting for it or for an earlier one. The choice is about them. + const lastChanged = Math.max( + ...changed.map((email) => sequence.emailsOrder.indexOf(email.emailId)), + ); + const affected = + schedule?.emails + .filter((entry) => sequence.emailsOrder.indexOf(entry.emailId) <= lastChanged) + .reduce((sum, entry) => sum + entry.waiting, 0) ?? null; + const scheduledFor = + sequence.type === "broadcast" ? broadcastScheduledFor(sequence) : null; + + async function publish() { + if (!sequence) return; + setPublishing(true); + setError(null); + try { + const updated = await publishSequenceChanges(sequence.sequenceId, { + emailIds, + scope: isSequence ? scope : undefined, + }); + onPublished(updated); + onOpenChange(false); + toast.success( + changed.length === 1 + ? "Changes published. New sends use the new version." + : `Changes to ${changed.length} emails published. New sends use them.`, + ); + } catch (err) { + setError(err instanceof ApiError ? err.message : "Failed to publish the changes"); + } finally { + setPublishing(false); + } + } + + const editHref = (email: SequenceEmail) => + isSequence + ? `/editor/sequences/${sequence.sequenceId}/emails/${email.emailId}` + : `/editor/broadcasts/${sequence.sequenceId}`; + + return ( + + + + Publish changes to {sequence.title || "Untitled"} + + {isSequence + ? "Contacts keep getting the published version until you publish. After that, these emails go out as they look in the editor." + : "The broadcast sends its published version until you publish. After that, it goes out as it looks in the editor."} + +
+ + {changed.length} email{changed.length === 1 ? "" : "s"} with unpublished + changes + + {isSequence && affected !== null && ( + + {affected.toLocaleString()} contact{affected === 1 ? "" : "s"} in the + sequence still to get {changed.length === 1 ? "it" : "them"} + + )} +
+
+ +
+ {error && ( + + + + )} + {changed.length === 0 && ( +

+ There's nothing to publish: every email is up to date. +

+ )} + {changed.map((email) => ( +
+ setOpen((current) => ({ ...current, [email.emailId]: value })) + } + icon={!} + name={email.draft?.subject || "Untitled email"} + summary={changeSummary(email)} + badge={Unpublished} + > +

+ Check the draft with Send test in the editor, then publish it. +

+ {email.draft && email.draft.subject !== email.subject && ( +
+
Published subject
+
{email.subject || "—"}
+
New subject
+
+ {email.draft.subject || "—"} +
+
+ )} + {/* Not on the editor page it would link to. */} + {pathname !== editHref(email) && ( + + )} +
+ ))} + {isSequence && changed.length > 0 && ( +
} + name="Who gets the changes" + summary="Contacts who enter from now on always get the new version." + badge={ + + {scope === "in_flight" ? "Everyone" : "New entrants only"} + + } + > +

+ Choose whether contacts already in the sequence get the new version too. +

+ setScope(value as PublishScope)} + className="gap-3" + > + + + +
+ )} +
+ + +

+ {scheduledFor + ? `Sends ${scheduledFor.toLocaleString()}. Earlier versions stay in the version history.` + : "Earlier versions stay in each email's version history."} +

+
+ + +
+
+
+
+ ); +} + +function ScopeOption({ + value, + label, + description, +}: { + value: PublishScope; + label: string; + description: string; +}) { + const id = `publish-scope-${value}`; + return ( +
+ + +
+ ); +} diff --git a/apps/web/components/dashboard/sequence-email-editor.tsx b/apps/web/components/dashboard/sequence-email-editor.tsx index 85e4140a..8c2c04f9 100644 --- a/apps/web/components/dashboard/sequence-email-editor.tsx +++ b/apps/web/components/dashboard/sequence-email-editor.tsx @@ -1,8 +1,9 @@ "use client"; -import type { Email } from "@sendlit/email-blocks"; -import { useEffect, useState } from "react"; +import type { Email, Sequence } from "@sendlit/email-blocks"; +import { useEffect, useRef, useState } from "react"; import { Banner } from "@/components/dashboard/banner"; +import { EditorDraftActions } from "@/components/dashboard/draft-controls"; import { EmailEditorScreen } from "@/components/dashboard/email-editor-screen"; import { Loading } from "@/components/dashboard/loading"; import { SendTestDialog } from "@/components/dashboard/send-test-dialog"; @@ -46,6 +47,13 @@ export function SequenceEmailEditor({ // A broadcast's subject is its title; the API keeps the two in step. const [isBroadcast, setIsBroadcast] = useState(false); const [savedSubject, setSavedSubject] = useState(""); + // The latest saved state, for "Unpublished changes" and Publish. + const [sequence, setSequence] = useState(); + const latestSequence = useRef(undefined); + function sequenceSaved(updated: Sequence) { + latestSequence.current = updated; + setSequence(updated); + } const [eventVariables, setEventVariables] = useState< { tag: string; description: string }[] >([]); @@ -57,6 +65,8 @@ export function SequenceEmailEditor({ .catch(() => undefined); getSequence(sequenceId) .then((sequence) => { + latestSequence.current = sequence; + setSequence(sequence); setDeliverySource(sequence.deliverySource ?? null); setViewInBrowser(sequence.viewInBrowser); setIsBroadcast(sequence.type === "broadcast"); @@ -81,11 +91,13 @@ export function SequenceEmailEditor({ setError("Email not found"); return; } - setSavedSubject(found.subject); + // The working copy: unpublished changes when there are some. + const subject = found.draft?.subject ?? found.subject; + setSavedSubject(subject); setEmail({ emailId: found.emailId, - subject: found.subject, - content: found.content, + subject, + content: found.draft?.content ?? found.content, lastTestSentAt: found.lastTestSentAt, }); }) @@ -95,7 +107,7 @@ export function SequenceEmailEditor({ }, [sequenceId, emailId]); if (error) return {error}; - if (!email) return ; + if (!email || !sequence) return ; return ( { if (isBroadcast && email.subject !== savedSubject) { await updateSequence(sequenceId, { title: email.subject }); } - await updateSequenceEmail(sequenceId, email.emailId, { - content, - ...(isBroadcast ? {} : { subject: email.subject }), - }); + sequenceSaved( + await updateSequenceEmail(sequenceId, email.emailId, { + content, + ...(isBroadcast ? {} : { subject: email.subject }), + }), + ); setSavedSubject(email.subject); }} actions={(saveContent) => ( - setEmail({ ...email, lastTestSentAt })} - /> + <> + setEmail({ ...email, lastTestSentAt })} + /> + { + // Publish what's on screen. + await saveContent(); + return latestSequence.current; + }} + onChange={sequenceSaved} + /> + )} /> ); diff --git a/apps/web/components/dashboard/sequence-list.tsx b/apps/web/components/dashboard/sequence-list.tsx index ec090f2a..24447a7e 100644 --- a/apps/web/components/dashboard/sequence-list.tsx +++ b/apps/web/components/dashboard/sequence-list.tsx @@ -7,10 +7,13 @@ import { MoreHorizontal, Pause, Play, + Radio, Trash2, + Workflow, } from "lucide-react"; import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; +import { AiAssistantHint } from "@/components/dashboard/ai-assistant-promo"; import { ArchiveBroadcastDialog } from "@/components/dashboard/archive-broadcast-dialog"; import { Banner } from "@/components/dashboard/banner"; import { useSetBreadcrumb } from "@/components/dashboard/breadcrumb-context"; @@ -19,6 +22,7 @@ import { ListPagination } from "@/components/dashboard/list-pagination"; import { Loading } from "@/components/dashboard/loading"; import { NewSequenceDialog } from "@/components/dashboard/new-sequence-dialog"; import { PageHeader } from "@/components/dashboard/page-header"; +import { UnpublishedChangesBadge } from "@/components/dashboard/publish-changes-dialog"; import { ScrollablePage } from "@/components/dashboard/scrollable-page"; import { SendErrorMessage } from "@/components/dashboard/send-error-message"; import { Badge } from "@/components/ui/codelit/badge"; @@ -37,6 +41,14 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/codelit/select"; +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@/components/ui/empty"; import { listSequences, pauseSequence, @@ -197,11 +209,43 @@ export function SequenceListPage({ ) : sequences.length === 0 ? ( - - {type === "broadcast" && archived === "only" - ? "No archived broadcasts. Archive a sent broadcast to hide it from the list." - : "Nothing here yet."} - + {type === "broadcast" && archived === "only" ? ( + + + + + + No archived broadcasts + + Archive a sent broadcast to hide it from the list. + + + + ) : ( + + + + {type === "broadcast" ? : } + + + {type === "broadcast" ? "No broadcasts yet" : "No sequences yet"} + + + {type === "broadcast" + ? "Send one email to your contacts, now or at a time you pick." + : "Send a series of emails automatically when a contact signs up or does something."} + + + + router.push(`${basePath}/${sequenceId}`)} + /> + + + + )} ) : ( @@ -226,20 +270,23 @@ export function SequenceListPage({ {sequence.title || "Untitled"}
- {type === "broadcast" ? ( - - - {presentBroadcastStatus(sequence).label} + + {type === "broadcast" ? ( + <> + + {presentBroadcastStatus(sequence).label} + + {sequence.archivedAt && ( + archived + )} + + ) : ( + + {sequence.status} - {sequence.archivedAt && ( - archived - )} - - ) : ( - - {sequence.status} - - )} + )} + {sequence.hasUnpublishedChanges && } + {sequence.emails.length} diff --git a/apps/web/components/ui/empty.tsx b/apps/web/components/ui/empty.tsx new file mode 100644 index 00000000..29b60758 --- /dev/null +++ b/apps/web/components/ui/empty.tsx @@ -0,0 +1,93 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@/lib/utils"; + +function Empty({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +const emptyMediaVariants = cva( + "mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0", + { + variants: { + variant: { + default: "bg-transparent", + icon: "flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-4", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +function EmptyMedia({ + className, + variant = "default", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ); +} + +function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) { + return ( +
a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary", + className, + )} + {...props} + /> + ); +} + +function EmptyContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle }; diff --git a/apps/web/components/ui/radio-group.tsx b/apps/web/components/ui/radio-group.tsx new file mode 100644 index 00000000..9248e662 --- /dev/null +++ b/apps/web/components/ui/radio-group.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { RadioGroup as RadioGroupPrimitive } from "radix-ui"; +import type * as React from "react"; +import { cn } from "@/lib/utils"; + +function RadioGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function RadioGroupItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + + ); +} + +export { RadioGroup, RadioGroupItem }; diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts index 7695fa98..4ad5628d 100644 --- a/apps/web/lib/api.ts +++ b/apps/web/lib/api.ts @@ -15,9 +15,11 @@ import { type ImportContactRow, type ImportContactsResult, type PaidPlan, + type PublishScope, type RenameTagResult, type Reputation, type SequenceDeletionPreview, + type SequenceEmailVersion, type SequenceSchedule, type SignupForm, type SignupFormInput, @@ -688,7 +690,46 @@ export function updateSequenceEmail( ); } -export type { ContactEvent, EventDefinitionDetail }; +/** Publishes unpublished email changes (all, or `emailIds`). `scope` is for + * running sequences: whether contacts already in it get them too. */ +export function publishSequenceChanges( + sequenceId: string, + body: { emailIds?: string[]; scope?: PublishScope } = {}, +) { + return unwrap( + client.sequences.publishChanges({ params: { sequenceId }, body }), + ); +} + +/** Throws away unpublished email changes (all, or `emailIds`). */ +export function discardSequenceChanges(sequenceId: string, emailIds?: string[]) { + return unwrap( + client.sequences.discardChanges({ params: { sequenceId }, body: { emailIds } }), + ); +} + +/** The versions an email was published with, live one first. */ +export async function listSequenceEmailVersions(sequenceId: string, emailId: string) { + const { items } = await unwrap<{ items: SequenceEmailVersion[] }>( + client.sequences.emailVersions({ params: { sequenceId, emailId } }), + ); + return items; +} + +/** Loads an earlier published version into the email's draft. */ +export function restoreSequenceEmailVersion( + sequenceId: string, + emailId: string, + versionId: string, +) { + return unwrap( + client.sequences.restoreEmailVersion({ + params: { sequenceId, emailId, versionId }, + }), + ); +} + +export type { ContactEvent, EventDefinitionDetail, PublishScope, SequenceEmailVersion }; export interface SequenceEmailTestResult { /** Event-triggered sequences: where `{{ event.* }}` came from. */ diff --git a/apps/web/lib/config.ts b/apps/web/lib/config.ts index c2f9904d..f597eb87 100644 --- a/apps/web/lib/config.ts +++ b/apps/web/lib/config.ts @@ -20,6 +20,12 @@ export function getMaxUploadSizeBytes() { return parsed > 0 ? parsed : DEFAULT_MAX_UPLOAD_SIZE_BYTES; } +/** The MCP server AI assistants connect to: the API's `/mcp`, on the API + * origin browsers and clients outside the cluster use. */ +export function getMcpUrl(): string { + return `${API_PUBLIC_URL.replace(/\/+$/, "")}/mcp`; +} + /** Public status page linked from the dashboard ("System status"), e.g. * `https://status.sendlit.app`. Server-only, like the settings below; unset * hides the link. */ diff --git a/docs/decisions/0011-draft-and-publish-for-running-emails.md b/docs/decisions/0011-draft-and-publish-for-running-emails.md new file mode 100644 index 00000000..7a8c1b9d --- /dev/null +++ b/docs/decisions/0011-draft-and-publish-for-running-emails.md @@ -0,0 +1,70 @@ +# ADR 0011: Draft and publish for emails of running broadcasts and sequences + +Status: accepted +Date: 2026-11-05 + +## Context + +Saving an email of an active sequence or a scheduled broadcast wrote straight +to the version that sends use (#184). Contacts reaching that step got +half-finished edits, typos and broken merge tags right away, and the only +workaround was to pause, edit and resume. + +Mailchimp, Kit, Customer.io, Braze and HubSpot all separate a working draft +from the published version and make going live an explicit step. Many let +the sender choose whether a change reaches contacts already in a journey or +only new entrants. + +## Decision + +- `sequence_emails.subject` and `content` stay the **published** version. + Sends, the previous release and every reader of those columns keep working + unchanged. Nullable `draft_subject`, `draft_content` and `draft_updated_at` + hold the working draft; both draft columns are set together or both null. +- Draft mode applies while a broadcast or sequence is `active`: a running + sequence or a scheduled broadcast. A draft or paused one sends nothing, so + edits save directly, as before, folding in any draft left from while it + was active. Drafting while paused was rejected: the dashboard treats a + cancelled schedule as a draft again and reschedules it with `start`, and an + auto-paused send is fixed by editing its content and resuming, so both + would silently send the old version. Resuming already rescans content. +- A draft equal to the published version is dropped, so "unpublished + changes" always means the two differ. +- Only subject and content are drafted: that is what a contact sees. Delay, + the step's `published` switch and tag actions still apply on save, like a + journey's structure in other tools. +- `POST /sequences/:id/publish-changes` (optional `emailIds`, `scope`) moves + drafts live; `discard-changes` drops them. Publishing runs the hosted + content scan that saving used to run, and refuses a broadcast that has + started sending. The publish is guarded on `draft_updated_at`, so an edit + saved during it stays a draft instead of going live unscanned. +- History reuses `email_content_versions` (already one immutable row per + distinct content, written at send time) with a new `published_at`. A + publish stamps the new version and records the one it replaces. `GET + .../emails/:emailId/versions` lists them, live first; `.../restore` loads a + version into the draft, so a rollback is published like any other change. +- `scope: "new_entrants"` writes `ongoing_sequences.pinned_versions` + (`emailId` to the replaced version's id) for contacts who haven't had that + email yet, in one statement. The send path renders a pinned version when + there is one. `in_flight` (the default, as the issue asks) removes pins + for that email, so everyone gets the new version at their next step. +- Test sends use the draft, so changes can be checked before publishing. +- The REST API, SDK and MCP all save to the draft by default; MCP gets + `publish_sequence_changes`, `discard_sequence_changes` (destructive), + `list_sequence_email_versions` and `restore_sequence_email_version`, so an + agent can't change what contacts receive without an explicit publish. + +## Consequences + +- Draft edits keep the published version's images referenced, so the media + library can't delete an image a live email still shows. +- Resuming a sequence paused with an open draft does not publish it. The + dashboard shows "Unpublished changes" and, for a scheduled broadcast, warns + that the published version will send. +- A broadcast's title is its published subject. While it's scheduled, a new + title is saved as the draft subject and becomes the title on publish, so a + sent broadcast (its list entry, report and web archive page) never shows a + title it wasn't sent with. +- When a broadcast starts sending (`lockBroadcast`), its open draft is + dropped: it can never go out, and leaving it would offer a Publish that + can only fail. diff --git a/packages/api-contract/src/contract.ts b/packages/api-contract/src/contract.ts index 304a08aa..a34dd374 100644 --- a/packages/api-contract/src/contract.ts +++ b/packages/api-contract/src/contract.ts @@ -117,12 +117,15 @@ import { import { addSequenceEmailBodySchema, createSequenceBodySchema, + discardSequenceChangesBodySchema, listSequencesQuerySchema, listSubscribersQuerySchema, listWaitingContactsQuerySchema, + publishSequenceChangesBodySchema, sendSequenceEmailTestBodySchema, sequenceDeletionPreviewSchema, sequenceEmailTestResultSchema, + sequenceEmailVersionSchema, sequenceScheduleSchema, sequenceSchema, sequenceStatsSchema, @@ -626,7 +629,7 @@ const sequencesContract = c.router( }, summary: "Update a broadcast or sequence", description: - "A broadcast's title is its email's subject, so once the broadcast has been sent, changing the title returns 400 with the error \"This broadcast has already been sent\". `viewInBrowser` can be changed at any time; turning it off also takes down the web versions linked from emails already sent.", + "A broadcast's title is its email's published subject. While it's scheduled, a new title is saved as the email's draft subject (see `draft`) and becomes the title when the changes are published. Once the broadcast has been sent, changing the title returns 400 with the error \"This broadcast has already been sent\". `viewInBrowser` can be changed at any time; turning it off also takes down the web versions linked from emails already sent.", }, remove: { method: "DELETE", @@ -688,7 +691,54 @@ const sequencesContract = c.router( }, summary: "Update an email within a sequence", description: - "When templateId is supplied, it must identify a marketing template; transactional templates return 422 template_not_marketing. Edits to an active sequence's email apply to later sends only: the \"view in browser\" page of an email already sent keeps showing what was sent. A sent broadcast can't be edited; the request returns it unchanged.", + "When templateId is supplied, it must identify a marketing template; transactional templates return 422 template_not_marketing. While a broadcast or sequence is active (a running sequence or a scheduled broadcast), `subject` and `content` are saved as a draft (returned as the email's `draft`) and sends keep using the published version until the changes are published with POST /sequences/{sequenceId}/publish-changes. Otherwise nothing is sending, so they're saved directly, along with any open draft. Delay, published and tag action changes always apply right away. The \"view in browser\" page of an email already sent keeps showing what was sent. A sent broadcast can't be edited; the request returns it unchanged.", + }, + publishChanges: { + method: "POST", + path: "/sequences/:sequenceId/publish-changes", + body: publishSequenceChangesBodySchema, + responses: { + 200: sequenceSchema, + 400: errorSchema, + 404: errorSchema, + 409: errorSchema, + }, + summary: "Publish the unpublished changes of a broadcast or sequence", + description: + "Makes the drafts of the given emails (or all of them) the version that sends use. Emails without unpublished changes are left alone, so publishing twice changes nothing. For a sequence, `scope` says whether contacts already in it get the new version at their next step (`in_flight`, the default) or keep the version they entered with (`new_entrants`). Each published version is kept in the email's history (GET /sequences/{sequenceId}/emails/{emailId}/versions), including the one it replaces. On SendLit Cloud the content is checked first, as when a sequence starts. 404 `email_not_found` for an email the sequence doesn't have; 409 `mail_already_sent` for a broadcast that has started sending (its unpublished changes are dropped then); 409 `draft_changed` when the draft was edited during the publish (publish again).", + }, + discardChanges: { + method: "POST", + path: "/sequences/:sequenceId/discard-changes", + body: discardSequenceChangesBodySchema, + responses: { 200: sequenceSchema, 404: errorSchema }, + summary: "Discard the unpublished changes of a broadcast or sequence", + description: + "Deletes the drafts of the given emails (or all of them), so they show the published version again. A broadcast's title goes back to its published subject. Sends are not affected.", + }, + emailVersions: { + method: "GET", + path: "/sequences/:sequenceId/emails/:emailId/versions", + responses: { + 200: itemsList(sequenceEmailVersionSchema), + 404: errorSchema, + }, + summary: "List the published versions of a broadcast or sequence email", + description: + "The live version first, then the versions the email was published with before, newest first (at most 20). Restore one with POST /sequences/{sequenceId}/emails/{emailId}/versions/{versionId}/restore.", + }, + restoreEmailVersion: { + method: "POST", + path: "/sequences/:sequenceId/emails/:emailId/versions/:versionId/restore", + body: c.noBody(), + responses: { + 200: sequenceSchema, + 404: errorSchema, + 409: errorSchema, + }, + summary: "Restore an earlier version of a broadcast or sequence email", + description: + "Loads a published version's subject and content into the email's draft; publish it to roll back. Unless the broadcast or sequence is active, it's restored directly. Restoring the live version discards the draft. A broadcast's title follows the restored subject once it's published. 404 `version_not_found` for a version that isn't one of this email's published versions; 409 `mail_already_sent` for a broadcast that has started sending.", }, sendEmailTest: { method: "POST", @@ -702,7 +752,7 @@ const sequencesContract = c.router( }, summary: "Send a test of a broadcast or sequence email", description: - "Renders the email exactly like a real send (merge tags, footer, tracking) and sends it with a `[Test]` subject prefix through the delivery source the real send would use. Its \"view in browser\" link shows the email as it was when tested, not later edits. Merge tags use the recipient's contact data when they are a contact. Opens, clicks and the unsubscribe link in a test change nothing. Suppressed addresses are skipped. Every recipient counts as one test: by default a team can send 100 test emails in any 24 hours (50 in any 3 hours) and 20 per email per 24 hours, after which this returns 429 `test_send_limit_reached` or `test_send_email_limit_reached`. Tests through an organization's shared mailbox also count against the team's shared-delivery quota (429 `organization_team_quota_exhausted` or `organization_quota_exhausted`).", + "Renders the email exactly like a real send (merge tags, footer, tracking) and sends it with a `[Test]` subject prefix through the delivery source the real send would use. When the email has unpublished changes, the test sends the draft, so they can be checked before they're published. Its \"view in browser\" link shows the email as it was when tested, not later edits. Merge tags use the recipient's contact data when they are a contact. Opens, clicks and the unsubscribe link in a test change nothing. Suppressed addresses are skipped. Every recipient counts as one test: by default a team can send 100 test emails in any 24 hours (50 in any 3 hours) and 20 per email per 24 hours, after which this returns 429 `test_send_limit_reached` or `test_send_email_limit_reached`. Tests through an organization's shared mailbox also count against the team's shared-delivery quota (429 `organization_team_quota_exhausted` or `organization_quota_exhausted`).", }, removeEmail: { method: "DELETE", diff --git a/packages/api-contract/src/mcp-parity.ts b/packages/api-contract/src/mcp-parity.ts index 0c8115cd..aea138c1 100644 --- a/packages/api-contract/src/mcp-parity.ts +++ b/packages/api-contract/src/mcp-parity.ts @@ -147,6 +147,10 @@ export const mcpParityManifest: readonly McpParityManifestEntry[] = [ required("sequences.unarchive", "unarchive_broadcast", "write"), required("sequences.addEmail", "add_sequence_email", "write"), required("sequences.updateEmail", "update_sequence_email", "write"), + required("sequences.publishChanges", "publish_sequence_changes", "write"), + required("sequences.discardChanges", "discard_sequence_changes", "destructive"), + required("sequences.emailVersions", "list_sequence_email_versions", "read"), + required("sequences.restoreEmailVersion", "restore_sequence_email_version", "write"), required("contacts.import", "import_contacts", "write"), required("suppressions.create", "add_suppression", "write"), required("forms.list", "list_signup_forms", "read"), diff --git a/packages/api-contract/src/schemas/sequences.ts b/packages/api-contract/src/schemas/sequences.ts index 8c62c003..ad60c65e 100644 --- a/packages/api-contract/src/schemas/sequences.ts +++ b/packages/api-contract/src/schemas/sequences.ts @@ -47,6 +47,18 @@ export const sequenceEmailSchema = z.object({ /** Hosted content check: `clean`, `review` (held for SendLit's review), * `blocked`, or `approved` after review; `null` when not scanned. */ contentScanVerdict: z.string().nullable().optional(), + /** Unpublished edits saved while the broadcast or sequence is active. `subject` and + * `content` above are the published version that sends use. */ + draft: z + .object({ + subject: z.string(), + content: emailContentSchema, + updatedAt: z.string().nullable(), + }) + .nullable() + .optional(), + /** Whether `draft` holds changes that aren't published yet. */ + hasUnpublishedChanges: z.boolean().optional(), createdAt: z.string().nullable(), updatedAt: z.string().nullable(), }); @@ -171,6 +183,8 @@ export const sequenceSchema = z.object({ createdAt: z.string().nullable(), updatedAt: z.string().nullable(), emails: z.array(sequenceEmailSchema), + /** Whether any of its emails has unpublished changes. */ + hasUnpublishedChanges: z.boolean().optional(), /** Set while a running broadcast of a new hosted organization is sent * gradually (`get` only). */ pacing: z @@ -269,6 +283,39 @@ export const updateSequenceEmailBodySchema = z.object({ published: z.boolean().optional(), }); +export const publishScopes = ["in_flight", "new_entrants"] as const; +export type PublishScope = (typeof publishScopes)[number]; + +const emailIdsSchema = z + .array(z.string()) + .min(1) + .optional() + .describe("The emails to act on; omit for every email with unpublished changes"); + +export const publishSequenceChangesBodySchema = z.object({ + emailIds: emailIdsSchema, + scope: z + .enum(publishScopes) + .optional() + .describe( + "Sequences only. in_flight (default): contacts already in the sequence get the new version at their next step. new_entrants: they keep the version they entered with; only contacts who enter from now on get the new one.", + ), +}); + +export const discardSequenceChangesBodySchema = z.object({ + emailIds: emailIdsSchema, +}); + +export const sequenceEmailVersionSchema = z.object({ + versionId: z.string(), + subject: z.string(), + content: emailContentSchema, + publishedAt: z.string().describe("When this version last went live"), + live: z.boolean().describe("Whether sends use this version now"), +}); + +export type SequenceEmailVersion = z.infer; + export const sendSequenceEmailTestBodySchema = z.object({ to: z .array(z.string().email()) diff --git a/packages/email-blocks/src/types.ts b/packages/email-blocks/src/types.ts index 33a2b410..4bc14ac3 100644 --- a/packages/email-blocks/src/types.ts +++ b/packages/email-blocks/src/types.ts @@ -124,6 +124,10 @@ export interface SequenceEmail { lastTestSentAt?: string | null; /** Hosted content check: clean, review, blocked or approved. */ contentScanVerdict?: string | null; + /** Unpublished edits saved while the broadcast or sequence is active; `subject` and + * `content` are the published version that sends use. */ + draft?: { subject: string; content: Email; updatedAt: string | null } | null; + hasUnpublishedChanges?: boolean; createdAt: string; updatedAt: string; } @@ -176,6 +180,8 @@ export interface Sequence { createdAt: string; updatedAt: string; emails: SequenceEmail[]; + /** Whether any of its emails has unpublished changes. */ + hasUnpublishedChanges?: boolean; /** Set while a new hosted account's broadcast is sent gradually. */ pacing?: { reason: "new_account"; diff --git a/packages/sendlit/README.md b/packages/sendlit/README.md index 1d27eb07..bf3a9c01 100644 --- a/packages/sendlit/README.md +++ b/packages/sendlit/README.md @@ -63,7 +63,7 @@ new SendLit({ | `sendlit.contacts` | `create`, `list`, `listAll`, `get`, `update`, `remove`, `addTag`, `removeTag`, `deliveries`, `events` | | `sendlit.segments` | `create`, `list`, `get`, `update`, `remove` | | `sendlit.templates` | `list`, `listSystem`, `get`, `create`, `update`, `duplicate`, `remove` | -| `sendlit.sequences` | Broadcasts and sequences: `create`, `list`, `listAll`, `get`, `update`, `remove`, `addEmail`, `updateEmail`, `removeEmail`, `start`, `pause`, `stats`, `subscribers` | +| `sendlit.sequences` | Broadcasts and sequences: `create`, `list`, `listAll`, `get`, `update`, `remove`, `addEmail`, `updateEmail`, `publishChanges`, `discardChanges`, `emailVersions`, `restoreEmailVersion`, `removeEmail`, `start`, `pause`, `stats`, `subscribers` | | `sendlit.emails` | Transactional email: `send`, `get`, `list`, `listAll` | | `sendlit.events` | Custom events: `track`, and `definitions.list`, `definitions.get`, `definitions.create`, `definitions.update`, `definitions.delete` | | `sendlit.provisioning` | Organization key only: `provisionTeam`, `getTeam`, `updateTeam`, `createTeamKey`, `suspendTeam`, `resumeTeam`, `archiveTeam`, `getTeamUsage` | @@ -229,6 +229,19 @@ Starting a broadcast or sequence requires a mailing address in the team's settings (set it with `mailingAddress` when provisioning) and a way to send email: either an organization delivery grant or the team's own ESP. +While a broadcast or sequence is active, `updateEmail` saves subject and +content changes as a draft; contacts keep getting the published version until +you publish: + +```ts +await sendlit.sequences.updateEmail(sequenceId, emailId, { subject: "Updated" }); +await sendlit.sequences.publishChanges(sequenceId, { + emailIds: [emailId], + // Sequences: "in_flight" (default) or "new_entrants". + scope: "new_entrants", +}); +``` + ## License MIT diff --git a/packages/sendlit/src/resources/sequences.ts b/packages/sendlit/src/resources/sequences.ts index c2098631..e22a7d0a 100644 --- a/packages/sendlit/src/resources/sequences.ts +++ b/packages/sendlit/src/resources/sequences.ts @@ -12,6 +12,9 @@ export type CreateSequenceInput = Body; export type UpdateSequenceInput = Body; export type AddSequenceEmailInput = Body; export type UpdateSequenceEmailInput = Body; +export type PublishSequenceChangesInput = Body; +export type DiscardSequenceChangesInput = Body; +export type SequenceEmailVersion = Ok["items"][number]; export type ListSequencesQuery = Query; export type ListSequenceSubscribersQuery = Query; @@ -72,6 +75,60 @@ export class Sequences { }); } + /** Publishes unpublished email changes (drafts) of a broadcast or + * sequence, so sends use them. While it's active, `updateEmail` saves + * subject and content changes as a draft. */ + async publishChanges( + sequenceId: string, + input: PublishSequenceChangesInput = {}, + ): Promise { + return this.http.request(routes.publishChanges, { + params: { sequenceId: assertId(sequenceId, "sequenceId") }, + body: input, + }); + } + + /** Throws away unpublished email changes. Sends are not affected. */ + async discardChanges( + sequenceId: string, + input: DiscardSequenceChangesInput = {}, + ): Promise { + return this.http.request(routes.discardChanges, { + params: { sequenceId: assertId(sequenceId, "sequenceId") }, + body: input, + }); + } + + /** The versions an email has been published with, live one first. */ + async emailVersions( + sequenceId: string, + emailId: string, + ): Promise { + const { items } = await this.http.request(routes.emailVersions, { + params: { + sequenceId: assertId(sequenceId, "sequenceId"), + emailId: assertId(emailId, "emailId"), + }, + }); + return items; + } + + /** Loads an earlier published version into the email's draft; publish + * it with `publishChanges` to roll back. */ + async restoreEmailVersion( + sequenceId: string, + emailId: string, + versionId: string, + ): Promise { + return this.http.request(routes.restoreEmailVersion, { + params: { + sequenceId: assertId(sequenceId, "sequenceId"), + emailId: assertId(emailId, "emailId"), + versionId: assertId(versionId, "versionId"), + }, + }); + } + async removeEmail(sequenceId: string, emailId: string): Promise { return this.http.request(routes.removeEmail, { params: {