From 66621055c34cbe3525e96506876ef2b08c211c6d Mon Sep 17 00:00:00 2001 From: Jared Zwick <52264361+jaredzwick@users.noreply.github.com> Date: Sun, 3 May 2026 07:01:10 -0400 Subject: [PATCH] hir-124: dedupe replyTriage + createReply on duplicate webhook delivery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gate the warm-reply triage path on the same alreadyReplied check the event-write already had, so a redelivered Gmail Pub/Sub (or repeated IMAP poll) hit doesn't double-write the reply row, double-bill the LLM triage call, or cancel a still-scheduled silent follow-up twice. Adds a unique index on reply (contact_id, event_id) as a belt-and-braces DB-layer invariant — Postgres treats NULL as distinct so legacy rows with a null event_id won't collide. Test additions: a two-delivery test that asserts triageReply, createReply, createEmailEvent, createReplyFollowup, and incrementCampaignStat each fire exactly once across the two calls; and rewrites the existing "alreadyReplied" test to match the new duplicate_delivery short-circuit (the old test was implicitly conflating duplicate webhooks with genuine new replies, which is the bug HIR-124 is fixing). --- .../db/drizzle/0009_damp_mulholland_black.sql | 1 + libs/db/drizzle/meta/0009_snapshot.json | 2418 +++++++++++++++++ libs/db/drizzle/meta/_journal.json | 7 + libs/db/src/schema.ts | 6 +- src/lib/replyFollowup.ts | 36 +- tests/int/replyFollowup.int.spec.ts | 63 +- 6 files changed, 2501 insertions(+), 30 deletions(-) create mode 100644 libs/db/drizzle/0009_damp_mulholland_black.sql create mode 100644 libs/db/drizzle/meta/0009_snapshot.json diff --git a/libs/db/drizzle/0009_damp_mulholland_black.sql b/libs/db/drizzle/0009_damp_mulholland_black.sql new file mode 100644 index 0000000..31733f2 --- /dev/null +++ b/libs/db/drizzle/0009_damp_mulholland_black.sql @@ -0,0 +1 @@ +CREATE UNIQUE INDEX "reply_contactId_eventId_unique" ON "reply" USING btree ("contact_id","event_id"); \ No newline at end of file diff --git a/libs/db/drizzle/meta/0009_snapshot.json b/libs/db/drizzle/meta/0009_snapshot.json new file mode 100644 index 0000000..8fbec3f --- /dev/null +++ b/libs/db/drizzle/meta/0009_snapshot.json @@ -0,0 +1,2418 @@ +{ + "id": "01583132-f52f-4dfe-baea-622fa98deb1b", + "prevId": "6aad738c-f2b6-4575-a0d7-924db4f6c81c", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "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.agency_user": { + "name": "agency_user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sub_agency_id": { + "name": "sub_agency_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agency_user_userId_idx": { + "name": "agency_user_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agency_user_subAgencyId_idx": { + "name": "agency_user_subAgencyId_idx", + "columns": [ + { + "expression": "sub_agency_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agency_user_user_id_user_id_fk": { + "name": "agency_user_user_id_user_id_fk", + "tableFrom": "agency_user", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agency_user_sub_agency_id_sub_agency_id_fk": { + "name": "agency_user_sub_agency_id_sub_agency_id_fk", + "tableFrom": "agency_user", + "tableTo": "sub_agency", + "columnsFrom": [ + "sub_agency_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hashed_key": { + "name": "hashed_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sub_agency_id": { + "name": "sub_agency_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_key_userId_idx": { + "name": "api_key_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_hashedKey_idx": { + "name": "api_key_hashedKey_idx", + "columns": [ + { + "expression": "hashed_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_subAgencyId_idx": { + "name": "api_key_subAgencyId_idx", + "columns": [ + { + "expression": "sub_agency_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_userId_subAgencyId_idx": { + "name": "api_key_userId_subAgencyId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sub_agency_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_sub_agency_id_sub_agency_id_fk": { + "name": "api_key_sub_agency_id_sub_agency_id_fk", + "tableFrom": "api_key", + "tableTo": "sub_agency", + "columnsFrom": [ + "sub_agency_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_hashed_key_unique": { + "name": "api_key_hashed_key_unique", + "nullsNotDistinct": false, + "columns": [ + "hashed_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_account": { + "name": "email_account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sub_agency_id": { + "name": "sub_agency_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "email_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "encrypted_access_token": { + "name": "encrypted_access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_refresh_token": { + "name": "encrypted_refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_expires_at": { + "name": "token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "email_account_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'connected'" + }, + "daily_quota": { + "name": "daily_quota", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 500 + }, + "quota_used_today": { + "name": "quota_used_today", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "quota_reset_at": { + "name": "quota_reset_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_account_userId_idx": { + "name": "email_account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_account_subAgencyId_idx": { + "name": "email_account_subAgencyId_idx", + "columns": [ + { + "expression": "sub_agency_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_account_email_idx": { + "name": "email_account_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_account_status_idx": { + "name": "email_account_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_account_tokenExpiresAt_idx": { + "name": "email_account_tokenExpiresAt_idx", + "columns": [ + { + "expression": "token_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_account_user_id_user_id_fk": { + "name": "email_account_user_id_user_id_fk", + "tableFrom": "email_account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_account_sub_agency_id_sub_agency_id_fk": { + "name": "email_account_sub_agency_id_sub_agency_id_fk", + "tableFrom": "email_account", + "tableTo": "sub_agency", + "columnsFrom": [ + "sub_agency_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_campaign": { + "name": "email_campaign", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sub_agency_id": { + "name": "sub_agency_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "campaign_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "total_recipients": { + "name": "total_recipients", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sent_count": { + "name": "sent_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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 + }, + "reply_count": { + "name": "reply_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "bounce_count": { + "name": "bounce_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "unsubscribe_count": { + "name": "unsubscribe_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_campaign_userId_idx": { + "name": "email_campaign_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_campaign_subAgencyId_idx": { + "name": "email_campaign_subAgencyId_idx", + "columns": [ + { + "expression": "sub_agency_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_campaign_status_idx": { + "name": "email_campaign_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_campaign_user_id_user_id_fk": { + "name": "email_campaign_user_id_user_id_fk", + "tableFrom": "email_campaign", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_campaign_sub_agency_id_sub_agency_id_fk": { + "name": "email_campaign_sub_agency_id_sub_agency_id_fk", + "tableFrom": "email_campaign", + "tableTo": "sub_agency", + "columnsFrom": [ + "sub_agency_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_event": { + "name": "email_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "queue_id": { + "name": "queue_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tracking_id": { + "name": "tracking_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "email_event_type", + "typeSchema": "public", + "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 + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "email_event_queueId_idx": { + "name": "email_event_queueId_idx", + "columns": [ + { + "expression": "queue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_event_trackingId_idx": { + "name": "email_event_trackingId_idx", + "columns": [ + { + "expression": "tracking_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_event_eventType_idx": { + "name": "email_event_eventType_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_event_timestamp_idx": { + "name": "email_event_timestamp_idx", + "columns": [ + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_event_queue_id_email_queue_id_fk": { + "name": "email_event_queue_id_email_queue_id_fk", + "tableFrom": "email_event", + "tableTo": "email_queue", + "columnsFrom": [ + "queue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_queue": { + "name": "email_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "campaign_id": { + "name": "campaign_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_account_id": { + "name": "email_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipient_email": { + "name": "recipient_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipient_name": { + "name": "recipient_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_html": { + "name": "body_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scheduled_for": { + "name": "scheduled_for", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "status": { + "name": "status", + "type": "email_queue_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tracking_id": { + "name": "tracking_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_queue_campaignId_idx": { + "name": "email_queue_campaignId_idx", + "columns": [ + { + "expression": "campaign_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_queue_emailAccountId_idx": { + "name": "email_queue_emailAccountId_idx", + "columns": [ + { + "expression": "email_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_queue_status_idx": { + "name": "email_queue_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_queue_scheduledFor_idx": { + "name": "email_queue_scheduledFor_idx", + "columns": [ + { + "expression": "scheduled_for", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_queue_trackingId_idx": { + "name": "email_queue_trackingId_idx", + "columns": [ + { + "expression": "tracking_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_queue_status_scheduledFor_idx": { + "name": "email_queue_status_scheduledFor_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scheduled_for", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_queue_campaign_id_email_campaign_id_fk": { + "name": "email_queue_campaign_id_email_campaign_id_fk", + "tableFrom": "email_queue", + "tableTo": "email_campaign", + "columnsFrom": [ + "campaign_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_queue_email_account_id_email_account_id_fk": { + "name": "email_queue_email_account_id_email_account_id_fk", + "tableFrom": "email_queue", + "tableTo": "email_account", + "columnsFrom": [ + "email_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "email_queue_tracking_id_unique": { + "name": "email_queue_tracking_id_unique", + "nullsNotDistinct": false, + "columns": [ + "tracking_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_template": { + "name": "email_template", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sub_agency_id": { + "name": "sub_agency_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_html": { + "name": "body_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_template_userId_idx": { + "name": "email_template_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_template_subAgencyId_idx": { + "name": "email_template_subAgencyId_idx", + "columns": [ + { + "expression": "sub_agency_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_template_user_id_user_id_fk": { + "name": "email_template_user_id_user_id_fk", + "tableFrom": "email_template", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_template_sub_agency_id_sub_agency_id_fk": { + "name": "email_template_sub_agency_id_sub_agency_id_fk", + "tableFrom": "email_template", + "tableTo": "sub_agency", + "columnsFrom": [ + "sub_agency_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_unsubscribe": { + "name": "email_unsubscribe", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "campaign_id": { + "name": "campaign_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_unsubscribe_email_idx": { + "name": "email_unsubscribe_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_unsubscribe_campaign_id_email_campaign_id_fk": { + "name": "email_unsubscribe_campaign_id_email_campaign_id_fk", + "tableFrom": "email_unsubscribe", + "tableTo": "email_campaign", + "columnsFrom": [ + "campaign_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "email_unsubscribe_email_unique": { + "name": "email_unsubscribe_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reply": { + "name": "reply", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "campaign_id": { + "name": "campaign_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "recipient_email": { + "name": "recipient_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipient_name": { + "name": "recipient_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "intent": { + "name": "intent", + "type": "reply_intent", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "confidence": { + "name": "confidence", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "suggested_followup": { + "name": "suggested_followup", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "reply_triage_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'new'" + }, + "sent_queue_id": { + "name": "sent_queue_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actioned_at": { + "name": "actioned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "received_at": { + "name": "received_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "reply_contactId_idx": { + "name": "reply_contactId_idx", + "columns": [ + { + "expression": "contact_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reply_campaignId_idx": { + "name": "reply_campaignId_idx", + "columns": [ + { + "expression": "campaign_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reply_status_idx": { + "name": "reply_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reply_intent_idx": { + "name": "reply_intent_idx", + "columns": [ + { + "expression": "intent", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reply_status_intent_idx": { + "name": "reply_status_intent_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "intent", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reply_receivedAt_idx": { + "name": "reply_receivedAt_idx", + "columns": [ + { + "expression": "received_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reply_contactId_eventId_unique": { + "name": "reply_contactId_eventId_unique", + "columns": [ + { + "expression": "contact_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reply_contact_id_email_queue_id_fk": { + "name": "reply_contact_id_email_queue_id_fk", + "tableFrom": "reply", + "tableTo": "email_queue", + "columnsFrom": [ + "contact_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reply_campaign_id_email_campaign_id_fk": { + "name": "reply_campaign_id_email_campaign_id_fk", + "tableFrom": "reply", + "tableTo": "email_campaign", + "columnsFrom": [ + "campaign_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reply_event_id_email_event_id_fk": { + "name": "reply_event_id_email_event_id_fk", + "tableFrom": "reply", + "tableTo": "email_event", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "reply_sent_queue_id_email_queue_id_fk": { + "name": "reply_sent_queue_id_email_queue_id_fk", + "tableFrom": "reply", + "tableTo": "email_queue", + "columnsFrom": [ + "sent_queue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reply_followup": { + "name": "reply_followup", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "sequence_id": { + "name": "sequence_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipient_email": { + "name": "recipient_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_reply_at": { + "name": "last_reply_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "last_reply_excerpt": { + "name": "last_reply_excerpt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scheduled_send_at": { + "name": "scheduled_send_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "reply_followup_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'scheduled'" + }, + "template_id": { + "name": "template_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sent_queue_id": { + "name": "sent_queue_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cancel_reason": { + "name": "cancel_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "reply_followup_sequenceId_idx": { + "name": "reply_followup_sequenceId_idx", + "columns": [ + { + "expression": "sequence_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reply_followup_contactId_idx": { + "name": "reply_followup_contactId_idx", + "columns": [ + { + "expression": "contact_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reply_followup_status_idx": { + "name": "reply_followup_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reply_followup_scheduledSendAt_idx": { + "name": "reply_followup_scheduledSendAt_idx", + "columns": [ + { + "expression": "scheduled_send_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reply_followup_status_scheduledSendAt_idx": { + "name": "reply_followup_status_scheduledSendAt_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scheduled_send_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reply_followup_sequence_id_email_campaign_id_fk": { + "name": "reply_followup_sequence_id_email_campaign_id_fk", + "tableFrom": "reply_followup", + "tableTo": "email_campaign", + "columnsFrom": [ + "sequence_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reply_followup_contact_id_email_queue_id_fk": { + "name": "reply_followup_contact_id_email_queue_id_fk", + "tableFrom": "reply_followup", + "tableTo": "email_queue", + "columnsFrom": [ + "contact_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reply_followup_template_id_email_template_id_fk": { + "name": "reply_followup_template_id_email_template_id_fk", + "tableFrom": "reply_followup", + "tableTo": "email_template", + "columnsFrom": [ + "template_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "reply_followup_sent_queue_id_email_queue_id_fk": { + "name": "reply_followup_sent_queue_id_email_queue_id_fk", + "tableFrom": "reply_followup", + "tableTo": "email_queue", + "columnsFrom": [ + "sent_queue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_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.sub_agency": { + "name": "sub_agency", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_agency_id": { + "name": "parent_agency_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sub_agency_ownerId_idx": { + "name": "sub_agency_ownerId_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sub_agency_parentAgencyId_idx": { + "name": "sub_agency_parentAgencyId_idx", + "columns": [ + { + "expression": "parent_agency_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sub_agency_owner_id_user_id_fk": { + "name": "sub_agency_owner_id_user_id_fk", + "tableFrom": "sub_agency", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "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 + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "sub_agency_id": { + "name": "sub_agency_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "verification_created_by_user_id_fk": { + "name": "verification_created_by_user_id_fk", + "tableFrom": "verification", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.campaign_status": { + "name": "campaign_status", + "schema": "public", + "values": [ + "draft", + "scheduled", + "sending", + "completed", + "paused" + ] + }, + "public.email_account_status": { + "name": "email_account_status", + "schema": "public", + "values": [ + "connected", + "disconnected", + "error" + ] + }, + "public.email_event_type": { + "name": "email_event_type", + "schema": "public", + "values": [ + "sent", + "opened", + "clicked", + "replied", + "bounced", + "unsubscribed" + ] + }, + "public.email_provider": { + "name": "email_provider", + "schema": "public", + "values": [ + "gmail", + "outlook", + "imap" + ] + }, + "public.email_queue_status": { + "name": "email_queue_status", + "schema": "public", + "values": [ + "pending", + "processing", + "sent", + "failed", + "bounced" + ] + }, + "public.reply_followup_status": { + "name": "reply_followup_status", + "schema": "public", + "values": [ + "scheduled", + "sent", + "cancelled" + ] + }, + "public.reply_intent": { + "name": "reply_intent", + "schema": "public", + "values": [ + "interested", + "objection", + "not_now", + "out_of_office" + ] + }, + "public.reply_triage_status": { + "name": "reply_triage_status", + "schema": "public", + "values": [ + "new", + "actioned", + "archived" + ] + }, + "public.role": { + "name": "role", + "schema": "public", + "values": [ + "admin", + "member", + "viewer" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/libs/db/drizzle/meta/_journal.json b/libs/db/drizzle/meta/_journal.json index bdc6503..69bf2aa 100644 --- a/libs/db/drizzle/meta/_journal.json +++ b/libs/db/drizzle/meta/_journal.json @@ -64,6 +64,13 @@ "when": 1777804426426, "tag": "0008_white_mongoose", "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1777806006150, + "tag": "0009_damp_mulholland_black", + "breakpoints": true } ] } \ No newline at end of file diff --git a/libs/db/src/schema.ts b/libs/db/src/schema.ts index 0a88863..dff3048 100644 --- a/libs/db/src/schema.ts +++ b/libs/db/src/schema.ts @@ -1,5 +1,5 @@ import { relations } from "drizzle-orm"; -import { pgTable, text, timestamp, boolean, index, pgEnum, integer, jsonb, real } from "drizzle-orm/pg-core"; +import { pgTable, text, timestamp, boolean, index, uniqueIndex, pgEnum, integer, jsonb, real } from "drizzle-orm/pg-core"; export const user = pgTable("user", { id: text("id").primaryKey(), @@ -464,6 +464,10 @@ export const reply = pgTable( index("reply_intent_idx").on(table.intent), index("reply_status_intent_idx").on(table.status, table.intent), index("reply_receivedAt_idx").on(table.receivedAt), + // Belt-and-braces dedupe: if the same email_event ever produced two reply + // rows for the same contact, that's a bug. Postgres treats NULL as + // distinct, so legacy rows with event_id IS NULL won't collide. + uniqueIndex("reply_contactId_eventId_unique").on(table.contactId, table.eventId), ] ); diff --git a/src/lib/replyFollowup.ts b/src/lib/replyFollowup.ts index f938275..ce68083 100644 --- a/src/lib/replyFollowup.ts +++ b/src/lib/replyFollowup.ts @@ -64,7 +64,7 @@ export interface RecordInboundReplyResult { followupCreated: boolean; followup?: ReplyFollowup; cancelledExistingCount: number; - reason?: "no_queue_entry" | "heuristic_no_match"; + reason?: "no_queue_entry" | "heuristic_no_match" | "duplicate_delivery"; /** Triaged reply row created for the warm-reply UI, if any. */ triagedReply?: Reply; } @@ -91,20 +91,28 @@ export const recordInboundReply = async ( } const alreadyReplied = await eventExistsForTracking(queueEntry.trackingId, "replied"); - let eventId: string | undefined; - if (!alreadyReplied) { - eventId = nanoid(); - await createEmailEvent({ - id: eventId, - queueId: queueEntry.id, - trackingId: queueEntry.trackingId, - eventType: "replied", - timestamp: replyAt, - metadata: { excerpt: buildExcerpt(input.replyBody) }, - }); - await incrementCampaignStat(queueEntry.campaignId, "replyCount"); + if (alreadyReplied) { + // Duplicate webhook delivery (Gmail Pub/Sub is at-least-once; IMAP pollers + // can repeat too). Short-circuit so we don't double-write the triaged reply + // row, double-bill the LLM call, or cancel a follow-up twice. + return { + followupCreated: false, + cancelledExistingCount: 0, + reason: "duplicate_delivery", + }; } + const eventId = nanoid(); + await createEmailEvent({ + id: eventId, + queueId: queueEntry.id, + trackingId: queueEntry.trackingId, + eventType: "replied", + timestamp: replyAt, + metadata: { excerpt: buildExcerpt(input.replyBody) }, + }); + await incrementCampaignStat(queueEntry.campaignId, "replyCount"); + // Warm-reply triage: classify intent + draft a follow-up so the user can // bucket and 1-click respond from /dashboard/replies. const triage = await triageReply({ @@ -116,7 +124,7 @@ export const recordInboundReply = async ( id: nanoid(), contactId: queueEntry.id, campaignId: queueEntry.campaignId, - eventId: eventId ?? null, + eventId, recipientEmail: queueEntry.recipientEmail, recipientName: queueEntry.recipientName, body: input.replyBody, diff --git a/tests/int/replyFollowup.int.spec.ts b/tests/int/replyFollowup.int.spec.ts index 27afe5e..3d24250 100644 --- a/tests/int/replyFollowup.int.spec.ts +++ b/tests/int/replyFollowup.int.spec.ts @@ -160,33 +160,66 @@ describe('recordInboundReply', () => { expect(dbMock.createEmailEvent).toHaveBeenCalled() }) - it('cancels existing scheduled followups when a new reply arrives (cancel-on-new-reply rule)', async () => { + it('short-circuits as duplicate_delivery when the replied event already exists for this trackingId', async () => { dbMock.getOriginalQueueEntry.mockResolvedValueOnce(queueEntry) dbMock.eventExistsForTracking.mockResolvedValueOnce(true) - dbMock.cancelScheduledFollowupsForContact.mockResolvedValueOnce(1) + + const result = await recordInboundReply({ + contactQueueId: 'queue-1', + replyBody: 'Actually, can you clarify pricing once more?', + }) + + expect(result).toEqual({ + followupCreated: false, + cancelledExistingCount: 0, + reason: 'duplicate_delivery', + }) + // No event, no campaign-stat bump, no LLM triage, no reply row, no + // followup scheduling, no cancellation of an existing followup. Anything + // else here means we're double-processing a redelivered webhook. + expect(dbMock.createEmailEvent).not.toHaveBeenCalled() + expect(dbMock.incrementCampaignStat).not.toHaveBeenCalled() + expect(triageMock.triageReply).not.toHaveBeenCalled() + expect(dbMock.createReply).not.toHaveBeenCalled() + expect(dbMock.cancelScheduledFollowupsForContact).not.toHaveBeenCalled() + expect(dbMock.createReplyFollowup).not.toHaveBeenCalled() + }) + + it('dedupes triage + createReply across two webhook deliveries with the same trackingId', async () => { + // First delivery: original reply lands, full pipeline runs. + dbMock.getOriginalQueueEntry.mockResolvedValueOnce(queueEntry) + dbMock.eventExistsForTracking.mockResolvedValueOnce(false) + dbMock.cancelScheduledFollowupsForContact.mockResolvedValueOnce(0) dbMock.createReplyFollowup.mockResolvedValueOnce({ - id: 'followup-2', + id: 'followup-1', sequenceId: 'campaign-1', contactId: 'queue-1', status: 'scheduled', }) - const result = await recordInboundReply({ + const first = await recordInboundReply({ contactQueueId: 'queue-1', - replyBody: 'Actually, can you clarify pricing once more?', + replyBody: 'Could you share pricing?', }) + expect(first.followupCreated).toBe(true) - expect(dbMock.cancelScheduledFollowupsForContact).toHaveBeenCalledWith( - 'queue-1', - 'superseded_by_new_reply' - ) - expect(result.cancelledExistingCount).toBe(1) - // duplicate reply event suppressed - expect(dbMock.createEmailEvent).not.toHaveBeenCalled() - expect(dbMock.incrementCampaignStat).not.toHaveBeenCalled() - // new heuristic-matching reply still schedules a fresh follow-up + // Second delivery (Pub/Sub redelivery): event already exists → short-circuit. + dbMock.getOriginalQueueEntry.mockResolvedValueOnce(queueEntry) + dbMock.eventExistsForTracking.mockResolvedValueOnce(true) + + const second = await recordInboundReply({ + contactQueueId: 'queue-1', + replyBody: 'Could you share pricing?', + }) + expect(second.reason).toBe('duplicate_delivery') + + // The expensive / user-visible side effects ran exactly once across both + // deliveries — proving dedup. + expect(dbMock.createEmailEvent).toHaveBeenCalledTimes(1) + expect(dbMock.createReply).toHaveBeenCalledTimes(1) + expect(triageMock.triageReply).toHaveBeenCalledTimes(1) expect(dbMock.createReplyFollowup).toHaveBeenCalledTimes(1) - expect(result.followupCreated).toBe(true) + expect(dbMock.incrementCampaignStat).toHaveBeenCalledTimes(1) }) it('returns no_queue_entry when the original outbound row cannot be found', async () => {