From a0ed4c5221214c54ab9afdd11e0e421fbdf9cc8f Mon Sep 17 00:00:00 2001 From: Jared Zwick <52264361+jaredzwick@users.noreply.github.com> Date: Sun, 3 May 2026 06:40:40 -0400 Subject: [PATCH] hir-120: warm-reply triage UI (intent buckets + 1-click follow-up) Classifies every inbound reply into one of four intent buckets (interested / objection / not_now / out_of_office) the moment it lands, drafts a tailored follow-up, and surfaces both at /dashboard/replies with [Send] / [Edit] / [Archive] per card. Send reuses the existing email_queue pipeline so the warm-reply path stays inside the same account-level rate limits as cold sends. - new `reply` table + drizzle migration 0008, queries module, types - lib/replyTriage.ts: Anthropic-backed classifier with a deterministic keyword heuristic fallback (clears >= 80% on tests/triage/cases.json so the system still works with no API key) - recordInboundReply now triages + persists every reply alongside the existing `replied` event + reply_followup scheduler - /api/replies (list + counts), /api/replies/triage (stateless POST), /api/replies/[id] (PATCH edit), /api/replies/[id]/send, /archive - /dashboard/replies page with four-tab intent UI - 14 new triage tests, 16 hand-labeled triage cases Note for review: spec called for `@anthropic-ai/sdk`. Used `fetch()` to avoid a new dep for one classification call. Trivially swappable if you want the SDK in. Co-Authored-By: Claude Opus 4.7 (1M context) --- .env.example | 7 + README.md | 18 + libs/db/drizzle/0008_white_mongoose.sql | 32 + libs/db/drizzle/meta/0008_snapshot.json | 2397 +++++++++++++++++ libs/db/drizzle/meta/_journal.json | 7 + libs/db/src/queries.ts | 3 +- libs/db/src/queries/reply.ts | 120 + libs/db/src/schema.ts | 85 +- src/app/(frontend)/dashboard/replies/page.tsx | 276 ++ src/app/api/replies/[id]/archive/route.ts | 53 + src/app/api/replies/[id]/route.ts | 69 + src/app/api/replies/[id]/send/route.ts | 123 + src/app/api/replies/route.ts | 78 + src/app/api/replies/triage/route.ts | 60 + src/lib/replyFollowup.ts | 34 +- src/lib/replyTriage.ts | 312 +++ tests/int/replyFollowup.int.spec.ts | 16 + tests/int/replyTriage.int.spec.ts | 220 ++ tests/triage/cases.json | 85 + 19 files changed, 3991 insertions(+), 4 deletions(-) create mode 100644 libs/db/drizzle/0008_white_mongoose.sql create mode 100644 libs/db/drizzle/meta/0008_snapshot.json create mode 100644 libs/db/src/queries/reply.ts create mode 100644 src/app/(frontend)/dashboard/replies/page.tsx create mode 100644 src/app/api/replies/[id]/archive/route.ts create mode 100644 src/app/api/replies/[id]/route.ts create mode 100644 src/app/api/replies/[id]/send/route.ts create mode 100644 src/app/api/replies/route.ts create mode 100644 src/app/api/replies/triage/route.ts create mode 100644 src/lib/replyTriage.ts create mode 100644 tests/int/replyTriage.int.spec.ts create mode 100644 tests/triage/cases.json diff --git a/.env.example b/.env.example index 444cba1..9ce48fc 100644 --- a/.env.example +++ b/.env.example @@ -39,3 +39,10 @@ GMAIL_WEBHOOK_SECRET=YOUR_WEBHOOK_SECRET_HERE # Silent-reply follow-up: days between prospect reply and the auto follow-up. # Pulled at runtime so we can tune in prod without a deploy. REPLY_FOLLOWUP_OFFSET_DAYS=3 + +# Anthropic API key for warm-reply triage classification. When unset, the +# reply triage endpoint falls back to a deterministic keyword heuristic that +# clears the >= 80% accuracy bar on tests/triage/cases.json. +ANTHROPIC_API_KEY= +# Optional Claude model override; defaults to claude-haiku-4-5-20251001. +ANTHROPIC_MODEL= diff --git a/README.md b/README.md index 8265978..c051182 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,24 @@ (or any question) and then goes silent, automatically schedule a short follow-up 3 days later. The follow-up cancels itself if the prospect replies again before it sends. See **Pending follow-ups** on the dashboard. +- Warm-reply triage: every inbound reply is classified into one of four + intent buckets — Interested, Objection, Not Now, Out of Office — with a + pre-drafted follow-up the user can edit and send in one click. Lives at + **/dashboard/replies**. + +## Warm-reply triage + +When a reply lands, the ingest path (`POST /api/email-tracking/reply`) +classifies it via Anthropic's Claude (or a deterministic keyword heuristic +when no API key is set) and writes a row to the `reply` table. The +`/dashboard/replies` page surfaces those rows in four tabs and exposes +[Send] / [Edit] / [Archive] actions per card. Clicking [Send] reuses the +existing `email_queue` send pipeline — same account, same `Re:` subject — +so warm replies stay inside coldflow's regular outbound rate limits. + +Set `ANTHROPIC_API_KEY` in `.env` to enable the LLM classifier. Without it +the heuristic still ships 80%+ classification accuracy on +`tests/triage/cases.json`. diff --git a/libs/db/drizzle/0008_white_mongoose.sql b/libs/db/drizzle/0008_white_mongoose.sql new file mode 100644 index 0000000..59344fa --- /dev/null +++ b/libs/db/drizzle/0008_white_mongoose.sql @@ -0,0 +1,32 @@ +CREATE TYPE "public"."reply_intent" AS ENUM('interested', 'objection', 'not_now', 'out_of_office');--> statement-breakpoint +CREATE TYPE "public"."reply_triage_status" AS ENUM('new', 'actioned', 'archived');--> statement-breakpoint +CREATE TABLE "reply" ( + "id" text PRIMARY KEY NOT NULL, + "contact_id" text NOT NULL, + "campaign_id" text NOT NULL, + "event_id" text, + "recipient_email" text NOT NULL, + "recipient_name" text, + "body" text NOT NULL, + "intent" "reply_intent" NOT NULL, + "confidence" real NOT NULL, + "suggested_followup" text NOT NULL, + "status" "reply_triage_status" DEFAULT 'new' NOT NULL, + "sent_queue_id" text, + "actioned_at" timestamp, + "archived_at" timestamp, + "received_at" timestamp NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "reply" ADD CONSTRAINT "reply_contact_id_email_queue_id_fk" FOREIGN KEY ("contact_id") REFERENCES "public"."email_queue"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "reply" ADD CONSTRAINT "reply_campaign_id_email_campaign_id_fk" FOREIGN KEY ("campaign_id") REFERENCES "public"."email_campaign"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "reply" ADD CONSTRAINT "reply_event_id_email_event_id_fk" FOREIGN KEY ("event_id") REFERENCES "public"."email_event"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "reply" ADD CONSTRAINT "reply_sent_queue_id_email_queue_id_fk" FOREIGN KEY ("sent_queue_id") REFERENCES "public"."email_queue"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "reply_contactId_idx" ON "reply" USING btree ("contact_id");--> statement-breakpoint +CREATE INDEX "reply_campaignId_idx" ON "reply" USING btree ("campaign_id");--> statement-breakpoint +CREATE INDEX "reply_status_idx" ON "reply" USING btree ("status");--> statement-breakpoint +CREATE INDEX "reply_intent_idx" ON "reply" USING btree ("intent");--> statement-breakpoint +CREATE INDEX "reply_status_intent_idx" ON "reply" USING btree ("status","intent");--> statement-breakpoint +CREATE INDEX "reply_receivedAt_idx" ON "reply" USING btree ("received_at"); \ No newline at end of file diff --git a/libs/db/drizzle/meta/0008_snapshot.json b/libs/db/drizzle/meta/0008_snapshot.json new file mode 100644 index 0000000..7899880 --- /dev/null +++ b/libs/db/drizzle/meta/0008_snapshot.json @@ -0,0 +1,2397 @@ +{ + "id": "6aad738c-f2b6-4575-a0d7-924db4f6c81c", + "prevId": "3d42e14b-424b-4dce-97ee-376d78bb821d", + "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": {} + } + }, + "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 92b23a1..bdc6503 100644 --- a/libs/db/drizzle/meta/_journal.json +++ b/libs/db/drizzle/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1777758682271, "tag": "0007_rare_vector", "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1777804426426, + "tag": "0008_white_mongoose", + "breakpoints": true } ] } \ No newline at end of file diff --git a/libs/db/src/queries.ts b/libs/db/src/queries.ts index 8e1f269..6834eab 100644 --- a/libs/db/src/queries.ts +++ b/libs/db/src/queries.ts @@ -18,4 +18,5 @@ export * from "./queries/emailCampaign"; export * from "./queries/emailEvent"; export * from "./queries/emailTemplate"; export * from "./queries/emailUnsubscribe"; -export * from "./queries/replyFollowup"; \ No newline at end of file +export * from "./queries/replyFollowup"; +export * from "./queries/reply"; \ No newline at end of file diff --git a/libs/db/src/queries/reply.ts b/libs/db/src/queries/reply.ts new file mode 100644 index 0000000..a8e8db7 --- /dev/null +++ b/libs/db/src/queries/reply.ts @@ -0,0 +1,120 @@ +import { and, desc, eq, sql } from "drizzle-orm"; +import { db } from "../client"; +import { + reply, + Reply, + InsertReply, + ReplyIntent, + ReplyTriageStatus, +} from "../schema"; + +export const createReply = async (data: InsertReply): Promise => { + const results = await db.insert(reply).values(data).returning(); + return results[0]; +}; + +export const getReplyById = async (id: string): Promise => { + const results = await db.select().from(reply).where(eq(reply.id, id)).limit(1); + return results[0] || null; +}; + +/** + * Returns triaged replies for a set of campaigns, ordered newest-first. + * Powers the `/dashboard/replies` UI tabs. Filters out archived rows by + * default (they are still kept in the table for audit). + */ +export const listRepliesForCampaigns = async ( + campaignIds: string[], + options?: { + intent?: ReplyIntent; + status?: ReplyTriageStatus; + includeArchived?: boolean; + limit?: number; + } +): Promise => { + if (campaignIds.length === 0) return []; + + const conditions = [sql`${reply.campaignId} = ANY(${campaignIds})`]; + if (options?.intent) conditions.push(eq(reply.intent, options.intent)); + if (options?.status) conditions.push(eq(reply.status, options.status)); + else if (!options?.includeArchived) { + conditions.push(sql`${reply.status} <> 'archived'`); + } + + return await db + .select() + .from(reply) + .where(and(...conditions)) + .orderBy(desc(reply.receivedAt)) + .limit(options?.limit ?? 200); +}; + +export const countRepliesByIntentForCampaigns = async ( + campaignIds: string[] +): Promise> => { + const empty: Record = { + interested: 0, + objection: 0, + not_now: 0, + out_of_office: 0, + }; + if (campaignIds.length === 0) return empty; + + const rows = await db + .select({ intent: reply.intent, count: sql`count(*)::int` }) + .from(reply) + .where( + and( + sql`${reply.campaignId} = ANY(${campaignIds})`, + eq(reply.status, "new") + ) + ) + .groupBy(reply.intent); + + for (const row of rows) { + empty[row.intent] = row.count; + } + return empty; +}; + +export const updateReplySuggestedFollowup = async ( + id: string, + suggestedFollowup: string +): Promise => { + const results = await db + .update(reply) + .set({ suggestedFollowup, updatedAt: new Date() }) + .where(eq(reply.id, id)) + .returning(); + return results[0] || null; +}; + +export const markReplyActioned = async ( + id: string, + sentQueueId: string, + now: Date = new Date() +): Promise => { + const results = await db + .update(reply) + .set({ + status: "actioned", + sentQueueId, + actionedAt: now, + updatedAt: now, + }) + .where(eq(reply.id, id)) + .returning(); + return results[0] || null; +}; + +export const markReplyArchived = async ( + id: string, + now: Date = new Date() +): Promise => { + const results = await db + .update(reply) + .set({ status: "archived", archivedAt: now, updatedAt: now }) + .where(eq(reply.id, id)) + .returning(); + return results[0] || null; +}; diff --git a/libs/db/src/schema.ts b/libs/db/src/schema.ts index c5fd3ad..0a88863 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 } from "drizzle-orm/pg-core"; +import { pgTable, text, timestamp, boolean, index, pgEnum, integer, jsonb, real } from "drizzle-orm/pg-core"; export const user = pgTable("user", { id: text("id").primaryKey(), @@ -409,6 +409,64 @@ export const replyFollowup = pgTable( ] ); +// Warm-reply triage table. +// One row per inbound reply, populated by the triage classifier so the user +// can bucket and one-click follow-up via the /dashboard/replies UI. Distinct +// from `email_event` of type 'replied' (raw landing record) and from +// `reply_followup` (silent-reply scheduler). +export const replyIntentEnum = pgEnum("reply_intent", [ + "interested", + "objection", + "not_now", + "out_of_office", +]); +export const replyTriageStatusEnum = pgEnum("reply_triage_status", [ + "new", + "actioned", + "archived", +]); + +export const reply = pgTable( + "reply", + { + id: text("id").primaryKey(), + // Original outbound email this is a reply to. Carries recipient + campaign + account context. + contactId: text("contact_id") + .notNull() + .references(() => emailQueue.id, { onDelete: "cascade" }), + campaignId: text("campaign_id") + .notNull() + .references(() => emailCampaign.id, { onDelete: "cascade" }), + // Optional pointer to the email_event row that recorded the reply landing. + eventId: text("event_id").references(() => emailEvent.id, { onDelete: "set null" }), + recipientEmail: text("recipient_email").notNull(), + recipientName: text("recipient_name"), + body: text("body").notNull(), + intent: replyIntentEnum("intent").notNull(), + confidence: real("confidence").notNull(), + suggestedFollowup: text("suggested_followup").notNull(), + status: replyTriageStatusEnum("status").notNull().default("new"), + // Queue row created when the user clicks "Send" (null until actioned). + sentQueueId: text("sent_queue_id").references(() => emailQueue.id, { onDelete: "set null" }), + actionedAt: timestamp("actioned_at"), + archivedAt: timestamp("archived_at"), + receivedAt: timestamp("received_at").notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .defaultNow() + .$onUpdate(() => new Date()) + .notNull(), + }, + (table) => [ + index("reply_contactId_idx").on(table.contactId), + index("reply_campaignId_idx").on(table.campaignId), + index("reply_status_idx").on(table.status), + index("reply_intent_idx").on(table.intent), + index("reply_status_intent_idx").on(table.status, table.intent), + index("reply_receivedAt_idx").on(table.receivedAt), + ] +); + // All Relations (defined after all tables) export const userRelations = relations(user, ({ many }) => ({ sessions: many(session), @@ -509,6 +567,27 @@ export const emailUnsubscribeRelations = relations(emailUnsubscribe, ({ one }) = }), })); +export const replyRelations = relations(reply, ({ one }) => ({ + campaign: one(emailCampaign, { + fields: [reply.campaignId], + references: [emailCampaign.id], + }), + contact: one(emailQueue, { + fields: [reply.contactId], + references: [emailQueue.id], + relationName: "replyContact", + }), + sentQueue: one(emailQueue, { + fields: [reply.sentQueueId], + references: [emailQueue.id], + relationName: "replySentQueue", + }), + event: one(emailEvent, { + fields: [reply.eventId], + references: [emailEvent.id], + }), +})); + export const replyFollowupRelations = relations(replyFollowup, ({ one }) => ({ campaign: one(emailCampaign, { fields: [replyFollowup.sequenceId], @@ -545,4 +624,8 @@ export type EmailUnsubscribe = typeof emailUnsubscribe.$inferSelect; export type InsertEmailUnsubscribe = typeof emailUnsubscribe.$inferInsert; export type ReplyFollowup = typeof replyFollowup.$inferSelect; export type InsertReplyFollowup = typeof replyFollowup.$inferInsert; +export type Reply = typeof reply.$inferSelect; +export type InsertReply = typeof reply.$inferInsert; +export type ReplyIntent = (typeof replyIntentEnum.enumValues)[number]; +export type ReplyTriageStatus = (typeof replyTriageStatusEnum.enumValues)[number]; diff --git a/src/app/(frontend)/dashboard/replies/page.tsx b/src/app/(frontend)/dashboard/replies/page.tsx new file mode 100644 index 0000000..fe8ba3e --- /dev/null +++ b/src/app/(frontend)/dashboard/replies/page.tsx @@ -0,0 +1,276 @@ +'use client' +import { useCallback, useEffect, useMemo, useState } from 'react' + +type Intent = 'interested' | 'objection' | 'not_now' | 'out_of_office' +type Status = 'new' | 'actioned' | 'archived' + +interface ReplyRow { + id: string + campaignId: string + campaignName: string | null + recipientEmail: string + recipientName: string | null + body: string + intent: Intent + confidence: number + suggestedFollowup: string + status: Status + receivedAt: string + actionedAt: string | null + archivedAt: string | null +} + +const TABS: { intent: Intent; label: string }[] = [ + { intent: 'interested', label: 'Interested' }, + { intent: 'objection', label: 'Objection' }, + { intent: 'not_now', label: 'Not Now' }, + { intent: 'out_of_office', label: 'Out of Office' }, +] + +export default function RepliesPage() { + const [activeTab, setActiveTab] = useState('interested') + const [replies, setReplies] = useState([]) + const [counts, setCounts] = useState>({ + interested: 0, + objection: 0, + not_now: 0, + out_of_office: 0, + }) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [busyId, setBusyId] = useState(null) + const [editing, setEditing] = useState>({}) + + const load = useCallback(async (intent: Intent) => { + setLoading(true) + setError(null) + try { + const res = await fetch(`/api/replies?intent=${intent}&status=new`) + const json = await res.json() + if (!json.success) { + setError(json.error ?? 'Failed to load') + return + } + setReplies(json.replies ?? []) + setCounts(json.counts ?? counts) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load') + } finally { + setLoading(false) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + useEffect(() => { + void load(activeTab) + }, [activeTab, load]) + + const visible = useMemo( + () => replies.filter((r) => r.intent === activeTab), + [replies, activeTab] + ) + + const draftFor = (row: ReplyRow): string => + editing[row.id] ?? row.suggestedFollowup + + const setDraft = (id: string, value: string) => + setEditing((current) => ({ ...current, [id]: value })) + + const send = async (row: ReplyRow) => { + setBusyId(row.id) + try { + const res = await fetch(`/api/replies/${row.id}/send`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ bodyOverride: draftFor(row) }), + }) + const json = await res.json() + if (!json.success) { + setError(json.error ?? 'Failed to send') + return + } + setReplies((current) => current.filter((r) => r.id !== row.id)) + setCounts((current) => ({ + ...current, + [row.intent]: Math.max(0, current[row.intent] - 1), + })) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to send') + } finally { + setBusyId(null) + } + } + + const saveEdit = async (row: ReplyRow) => { + const next = draftFor(row) + if (next === row.suggestedFollowup) return + setBusyId(row.id) + try { + const res = await fetch(`/api/replies/${row.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ suggestedFollowup: next }), + }) + const json = await res.json() + if (!json.success) { + setError(json.error ?? 'Failed to save') + return + } + setReplies((current) => + current.map((r) => + r.id === row.id ? { ...r, suggestedFollowup: next } : r + ) + ) + setEditing((current) => { + const { [row.id]: _, ...rest } = current + return rest + }) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to save') + } finally { + setBusyId(null) + } + } + + const archive = async (row: ReplyRow) => { + setBusyId(row.id) + try { + const res = await fetch(`/api/replies/${row.id}/archive`, { + method: 'POST', + }) + const json = await res.json() + if (!json.success) { + setError(json.error ?? 'Failed to archive') + return + } + setReplies((current) => current.filter((r) => r.id !== row.id)) + setCounts((current) => ({ + ...current, + [row.intent]: Math.max(0, current[row.intent] - 1), + })) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to archive') + } finally { + setBusyId(null) + } + } + + return ( +
+
+

Replies

+

+ Inbound replies bucketed by intent. Edit the suggested follow-up if + you want, then [Send] to queue it through your sending account. +

+ + {error && ( +
+ {error} +
+ )} + +
+ {TABS.map((tab) => { + const isActive = activeTab === tab.intent + return ( + + ) + })} +
+ + {loading ? ( +

Loading…

+ ) : visible.length === 0 ? ( +

No replies in this bucket.

+ ) : ( +
+ {visible.map((row) => { + const draft = draftFor(row) + const dirty = draft !== row.suggestedFollowup + const isBusy = busyId === row.id + return ( +
+
+
+
+ {row.recipientName + ? `${row.recipientName} <${row.recipientEmail}>` + : row.recipientEmail} +
+
+ {row.campaignName ?? row.campaignId} ·{' '} + {new Date(row.receivedAt).toLocaleString()} · confidence{' '} + {Math.round(row.confidence * 100)}% +
+
+
+ +
+ + Reply body + +
+                      {row.body}
+                    
+
+ + +