diff --git a/apps/docs/api-reference/openapi.json b/apps/docs/api-reference/openapi.json index ce39eb17..c2d886dc 100644 --- a/apps/docs/api-reference/openapi.json +++ b/apps/docs/api-reference/openapi.json @@ -2085,6 +2085,39 @@ "type": "integer", "minimum": 1, "maximum": 100000 + }, + "delivery": { + "oneOf": [ + { + "type": "object", + "properties": { + "strategy": { + "type": "string", + "enum": ["all_at_once"] + } + }, + "required": ["strategy"] + }, + { + "type": "object", + "properties": { + "strategy": { + "type": "string", + "enum": ["gradual"] + }, + "batchPercentage": { + "type": "integer", + "minimum": 1, + "maximum": 50 + }, + "interval": { + "type": "string", + "enum": ["minute", "hour"] + } + }, + "required": ["strategy", "batchPercentage", "interval"] + } + ] } }, "required": ["name", "from", "subject", "contactBookId"] @@ -2116,6 +2149,29 @@ }, "batchSize": { "type": "integer" }, "batchWindowMinutes": { "type": "integer" }, + "deliveryMode": { + "type": "string", + "enum": ["ALL_AT_ONCE", "GRADUAL"] + }, + "deliveryBatchPercentage": { + "type": "integer", + "nullable": true + }, + "deliveryIntervalMinutes": { + "type": "integer", + "nullable": true + }, + "deliveryBatchSize": { + "type": "integer", + "nullable": true + }, + "currentDeliveryBatch": { "type": "integer" }, + "deliveryBatchProcessed": { "type": "integer" }, + "nextDeliveryAt": { + "type": "string", + "nullable": true, + "format": "date-time" + }, "total": { "type": "integer" }, "sent": { "type": "integer" }, "delivered": { "type": "integer" }, @@ -2147,6 +2203,13 @@ "scheduledAt", "batchSize", "batchWindowMinutes", + "deliveryMode", + "deliveryBatchPercentage", + "deliveryIntervalMinutes", + "deliveryBatchSize", + "currentDeliveryBatch", + "deliveryBatchProcessed", + "nextDeliveryAt", "total", "sent", "delivered", @@ -2302,6 +2365,29 @@ }, "batchSize": { "type": "integer" }, "batchWindowMinutes": { "type": "integer" }, + "deliveryMode": { + "type": "string", + "enum": ["ALL_AT_ONCE", "GRADUAL"] + }, + "deliveryBatchPercentage": { + "type": "integer", + "nullable": true + }, + "deliveryIntervalMinutes": { + "type": "integer", + "nullable": true + }, + "deliveryBatchSize": { + "type": "integer", + "nullable": true + }, + "currentDeliveryBatch": { "type": "integer" }, + "deliveryBatchProcessed": { "type": "integer" }, + "nextDeliveryAt": { + "type": "string", + "nullable": true, + "format": "date-time" + }, "total": { "type": "integer" }, "sent": { "type": "integer" }, "delivered": { "type": "integer" }, @@ -2333,6 +2419,13 @@ "scheduledAt", "batchSize", "batchWindowMinutes", + "deliveryMode", + "deliveryBatchPercentage", + "deliveryIntervalMinutes", + "deliveryBatchSize", + "currentDeliveryBatch", + "deliveryBatchProcessed", + "nextDeliveryAt", "total", "sent", "delivered", @@ -2391,6 +2484,29 @@ }, "batchSize": { "type": "integer" }, "batchWindowMinutes": { "type": "integer" }, + "deliveryMode": { + "type": "string", + "enum": ["ALL_AT_ONCE", "GRADUAL"] + }, + "deliveryBatchPercentage": { + "type": "integer", + "nullable": true + }, + "deliveryIntervalMinutes": { + "type": "integer", + "nullable": true + }, + "deliveryBatchSize": { + "type": "integer", + "nullable": true + }, + "currentDeliveryBatch": { "type": "integer" }, + "deliveryBatchProcessed": { "type": "integer" }, + "nextDeliveryAt": { + "type": "string", + "nullable": true, + "format": "date-time" + }, "total": { "type": "integer" }, "sent": { "type": "integer" }, "delivered": { "type": "integer" }, @@ -2422,6 +2538,13 @@ "scheduledAt", "batchSize", "batchWindowMinutes", + "deliveryMode", + "deliveryBatchPercentage", + "deliveryIntervalMinutes", + "deliveryBatchSize", + "currentDeliveryBatch", + "deliveryBatchProcessed", + "nextDeliveryAt", "total", "sent", "delivered", @@ -2473,6 +2596,39 @@ "type": "integer", "minimum": 1, "maximum": 100000 + }, + "delivery": { + "oneOf": [ + { + "type": "object", + "properties": { + "strategy": { + "type": "string", + "enum": ["all_at_once"] + } + }, + "required": ["strategy"] + }, + { + "type": "object", + "properties": { + "strategy": { + "type": "string", + "enum": ["gradual"] + }, + "batchPercentage": { + "type": "integer", + "minimum": 1, + "maximum": 50 + }, + "interval": { + "type": "string", + "enum": ["minute", "hour"] + } + }, + "required": ["strategy", "batchPercentage", "interval"] + } + ] } } } diff --git a/apps/web/prisma/migrations/20260721120000_add_gradual_campaign_sending/migration.sql b/apps/web/prisma/migrations/20260721120000_add_gradual_campaign_sending/migration.sql new file mode 100644 index 00000000..c3181c45 --- /dev/null +++ b/apps/web/prisma/migrations/20260721120000_add_gradual_campaign_sending/migration.sql @@ -0,0 +1,26 @@ +-- CreateEnum +CREATE TYPE "CampaignDeliveryMode" AS ENUM ('ALL_AT_ONCE', 'GRADUAL'); + +-- CreateEnum +CREATE TYPE "CampaignRecipientStatus" AS ENUM ('PENDING', 'PROCESSING', 'QUEUED', 'SUPPRESSED', 'SKIPPED', 'FAILED'); + +-- AlterTable +ALTER TABLE "CampaignEmail" ALTER COLUMN "emailId" DROP NOT NULL; +-- PostgreSQL 11+ stores a constant default as metadata instead of rewriting +-- every existing row. Existing recipients are already queued, while new rows +-- created by the gradual delivery worker should default to pending. +ALTER TABLE "CampaignEmail" ADD COLUMN "status" "CampaignRecipientStatus" NOT NULL DEFAULT 'QUEUED'; +ALTER TABLE "CampaignEmail" ADD COLUMN "processedAt" TIMESTAMP(3); +ALTER TABLE "CampaignEmail" ALTER COLUMN "status" SET DEFAULT 'PENDING'; + +-- AlterTable +ALTER TABLE "Campaign" ADD COLUMN "deliveryMode" "CampaignDeliveryMode" NOT NULL DEFAULT 'ALL_AT_ONCE'; +ALTER TABLE "Campaign" ADD COLUMN "deliveryBatchPercentage" INTEGER; +ALTER TABLE "Campaign" ADD COLUMN "deliveryIntervalMinutes" INTEGER; +ALTER TABLE "Campaign" ADD COLUMN "deliveryBatchSize" INTEGER; +ALTER TABLE "Campaign" ADD COLUMN "currentDeliveryBatch" INTEGER NOT NULL DEFAULT 0; +ALTER TABLE "Campaign" ADD COLUMN "deliveryBatchProcessed" INTEGER NOT NULL DEFAULT 0; +ALTER TABLE "Campaign" ADD COLUMN "nextDeliveryAt" TIMESTAMP(3); +ALTER TABLE "Campaign" ADD COLUMN "audienceCapturedAt" TIMESTAMP(3); +ALTER TABLE "Campaign" ADD COLUMN "audiencePreparedAt" TIMESTAMP(3); +ALTER TABLE "Campaign" ADD COLUMN "pausedAt" TIMESTAMP(3); diff --git a/apps/web/prisma/migrations/20260721170000_add_campaign_email_recipient_status_index/migration.sql b/apps/web/prisma/migrations/20260721170000_add_campaign_email_recipient_status_index/migration.sql new file mode 100644 index 00000000..c1bf8db6 --- /dev/null +++ b/apps/web/prisma/migrations/20260721170000_add_campaign_email_recipient_status_index/migration.sql @@ -0,0 +1,4 @@ +-- CreateIndex +-- Keep this as the migration's only SQL statement so PostgreSQL can build the +-- index without blocking inserts or updates. +CREATE INDEX CONCURRENTLY "CampaignEmail_campaignId_status_contactId_idx" ON "CampaignEmail"("campaignId", "status", "contactId"); diff --git a/apps/web/prisma/migrations/20260721180000_add_campaign_next_delivery_index/migration.sql b/apps/web/prisma/migrations/20260721180000_add_campaign_next_delivery_index/migration.sql new file mode 100644 index 00000000..43292423 --- /dev/null +++ b/apps/web/prisma/migrations/20260721180000_add_campaign_next_delivery_index/migration.sql @@ -0,0 +1,4 @@ +-- CreateIndex +-- Keep this as the migration's only SQL statement so PostgreSQL can build the +-- index without blocking inserts or updates. +CREATE INDEX CONCURRENTLY "Campaign_status_nextDeliveryAt_idx" ON "Campaign"("status", "nextDeliveryAt"); diff --git a/apps/web/prisma/migrations/20260721190000_add_campaign_email_status_index/migration.sql b/apps/web/prisma/migrations/20260721190000_add_campaign_email_status_index/migration.sql new file mode 100644 index 00000000..f1bc1458 --- /dev/null +++ b/apps/web/prisma/migrations/20260721190000_add_campaign_email_status_index/migration.sql @@ -0,0 +1,4 @@ +-- CreateIndex +-- Keep this as the migration's only SQL statement so Prisma runs it outside a +-- transaction and PostgreSQL can build it without blocking inserts or updates. +CREATE INDEX CONCURRENTLY "Email_campaignId_latestStatus_idx" ON "Email"("campaignId", "latestStatus"); diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index 1492adf8..b6283407 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -267,16 +267,20 @@ model Email { emailEvents EmailEvent[] @@index([campaignId, contactId]) + @@index([campaignId, latestStatus]) @@index([createdAt(sort: Desc)]) } model CampaignEmail { - campaignId String - contactId String - emailId String - createdAt DateTime @default(now()) + campaignId String + contactId String + emailId String? + status CampaignRecipientStatus @default(PENDING) + processedAt DateTime? + createdAt DateTime @default(now()) @@id([campaignId, contactId]) + @@index([campaignId, status, contactId]) } model EmailEvent { @@ -349,43 +353,68 @@ enum CampaignStatus { SENT } +enum CampaignDeliveryMode { + ALL_AT_ONCE + GRADUAL +} + +enum CampaignRecipientStatus { + PENDING + PROCESSING + QUEUED + SUPPRESSED + SKIPPED + FAILED +} + model Campaign { - id String @id @default(cuid()) - name String - teamId Int - from String - cc String[] - bcc String[] - replyTo String[] - domainId Int - subject String - previewText String? - html String? - content String? - contactBookId String? - scheduledAt DateTime? - total Int @default(0) - sent Int @default(0) - delivered Int @default(0) - opened Int @default(0) - clicked Int @default(0) - unsubscribed Int @default(0) - bounced Int @default(0) - hardBounced Int @default(0) - complained Int @default(0) - isApi Boolean @default(false) - status CampaignStatus @default(DRAFT) - batchSize Int @default(500) - batchWindowMinutes Int @default(0) - lastCursor String? - lastSentAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + name String + teamId Int + from String + cc String[] + bcc String[] + replyTo String[] + domainId Int + subject String + previewText String? + html String? + content String? + contactBookId String? + scheduledAt DateTime? + total Int @default(0) + sent Int @default(0) + delivered Int @default(0) + opened Int @default(0) + clicked Int @default(0) + unsubscribed Int @default(0) + bounced Int @default(0) + hardBounced Int @default(0) + complained Int @default(0) + isApi Boolean @default(false) + status CampaignStatus @default(DRAFT) + batchSize Int @default(500) + batchWindowMinutes Int @default(0) + lastCursor String? + lastSentAt DateTime? + deliveryMode CampaignDeliveryMode @default(ALL_AT_ONCE) + deliveryBatchPercentage Int? + deliveryIntervalMinutes Int? + deliveryBatchSize Int? + currentDeliveryBatch Int @default(0) + deliveryBatchProcessed Int @default(0) + nextDeliveryAt DateTime? + audienceCapturedAt DateTime? + audiencePreparedAt DateTime? + pausedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) @@index([createdAt(sort: Desc)]) @@index([status, scheduledAt]) + @@index([status, nextDeliveryAt]) } model Template { diff --git a/apps/web/src/app/(dashboard)/campaigns/[campaignId]/page.tsx b/apps/web/src/app/(dashboard)/campaigns/[campaignId]/page.tsx index 93aeaf66..b8768ee1 100644 --- a/apps/web/src/app/(dashboard)/campaigns/[campaignId]/page.tsx +++ b/apps/web/src/app/(dashboard)/campaigns/[campaignId]/page.tsx @@ -22,6 +22,7 @@ import { Button } from "@usesend/ui/src/button"; import { Card, CardContent, CardHeader, CardTitle } from "@usesend/ui/src/card"; import { EmailStatusBadge } from "../../emails/email-status-badge"; import { AnimatePresence, motion } from "framer-motion"; +import { Clock3, Send } from "lucide-react"; export default function CampaignDetailsPage({ params, @@ -38,6 +39,7 @@ export default function CampaignDetailsPage({ if (!c) return false; if ( + c.status === CampaignStatus.SCHEDULED || c.status === CampaignStatus.RUNNING || c.status === CampaignStatus.PAUSED ) { @@ -45,7 +47,7 @@ export default function CampaignDetailsPage({ } return false; }, - } + }, ); const { data: latestEmails, isLoading: latestEmailsLoading } = @@ -53,9 +55,38 @@ export default function CampaignDetailsPage({ { campaignId: campaignId }, { refetchInterval: 5000, - } + }, ); + const { data: deliveryProgress } = api.campaign.getDeliveryProgress.useQuery( + { campaignId }, + { + refetchInterval: (query) => { + if ( + campaign?.status === CampaignStatus.SCHEDULED || + campaign?.status === CampaignStatus.RUNNING + ) { + return 5000; + } + + const progress = query.state.data; + if ( + campaign?.status === CampaignStatus.PAUSED && + progress?.processing + ) { + return 5000; + } + + const needsFinalSnapshot = + campaign?.status === CampaignStatus.SENT && + progress != null && + progress.pending > 0; + + return needsFinalSnapshot ? 5000 : false; + }, + }, + ); + if (isLoading) { return (
@@ -100,7 +131,47 @@ export default function CampaignDetailsPage({ ]; const total = campaign.total ?? 0; - const processed = campaign.sent ?? 0; + const processed = deliveryProgress?.processed ?? campaign.sent ?? 0; + const awaiting = Math.max(deliveryProgress?.pending ?? 0, total - processed); + const completionPercentage = + total > 0 ? Math.min(100, (processed / total) * 100) : 0; + const deliveryBatchSize = campaign.deliveryBatchSize ?? 0; + const totalDeliveryBatches = + deliveryBatchSize > 0 ? Math.ceil(total / deliveryBatchSize) : 0; + const currentDeliveryBatch = campaign.currentDeliveryBatch ?? 0; + const isGradualDelivery = campaign.deliveryMode === "GRADUAL"; + const showDeliveryCard = + campaign.status === CampaignStatus.SCHEDULED || + campaign.status === CampaignStatus.RUNNING || + campaign.status === CampaignStatus.PAUSED; + const nextDeliveryAt = campaign.nextDeliveryAt + ? new Date(campaign.nextDeliveryAt) + : null; + const nextDeliveryLabel = nextDeliveryAt + ? nextDeliveryAt.getTime() <= Date.now() + ? "Due now" + : formatDistanceToNow(nextDeliveryAt, { addSuffix: true }) + : null; + + const deliveryMetrics = [ + { label: "Awaiting", value: awaiting }, + { label: "Queued", value: deliveryProgress?.queued ?? 0 }, + { label: "Sent", value: deliveryProgress?.sent ?? campaign.sent ?? 0 }, + { label: "Failed", value: deliveryProgress?.failed ?? 0 }, + { label: "Suppressed", value: deliveryProgress?.suppressed ?? 0 }, + ]; + + const completedDeliverySummary = [ + isGradualDelivery && totalDeliveryBatches > 0 + ? `Sent in ${totalDeliveryBatches.toLocaleString()} ${ + totalDeliveryBatches === 1 ? "wave" : "waves" + }` + : "Sent all at once", + `${processed.toLocaleString()} processed`, + ...(deliveryProgress?.failed + ? [`${deliveryProgress.failed.toLocaleString()} failed`] + : []), + ].join(" ยท "); return (
@@ -134,13 +205,100 @@ export default function CampaignDetailsPage({ )}
-
+
+