feat: batch campaigns (#227)

This commit is contained in:
KM Koushik
2025-10-12 22:43:16 +11:00
committed by GitHub
parent 159b15e37e
commit e631f16c85
22 changed files with 13574 additions and 6314 deletions
+6 -2
View File
@@ -14,7 +14,11 @@
"db:migrate-dev": "prisma migrate dev",
"db:migrate-deploy": "prisma migrate deploy",
"db:studio": "prisma studio",
"db:migrate-reset": "prisma migrate reset"
"db:migrate-reset": "prisma migrate reset",
"memory:monitor": "node --expose-gc scripts/memory-monitor.js",
"memory:profile": "node --expose-gc scripts/memory-profiler.js",
"memory:test": "node --expose-gc -e \"const MemoryMonitor = require('./scripts/memory-monitor'); const monitor = new MemoryMonitor(); monitor.start(1000); setTimeout(() => monitor.stop(), 30000)\"",
"memory:baseline": "node --expose-gc scripts/baseline-test.js"
},
"dependencies": {
"@auth/prisma-adapter": "^2.9.0",
@@ -100,4 +104,4 @@
"initVersion": "7.30.0"
},
"packageManager": "pnpm@8.9.2"
}
}
@@ -0,0 +1,36 @@
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "CampaignStatus" ADD VALUE 'RUNNING';
ALTER TYPE "CampaignStatus" ADD VALUE 'PAUSED';
-- AlterTable
ALTER TABLE "Campaign" ADD COLUMN "batchSize" INTEGER NOT NULL DEFAULT 500,
ADD COLUMN "batchWindowMinutes" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "lastCursor" TEXT,
ADD COLUMN "lastSentAt" TIMESTAMP(3),
ADD COLUMN "scheduledAt" TIMESTAMP(3);
-- CreateTable
CREATE TABLE "CampaignEmail" (
"campaignId" TEXT NOT NULL,
"contactId" TEXT NOT NULL,
"emailId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "CampaignEmail_pkey" PRIMARY KEY ("campaignId","contactId")
);
-- CreateIndex
CREATE INDEX "Campaign_status_scheduledAt_idx" ON "Campaign"("status", "scheduledAt");
-- CreateIndex
CREATE INDEX "Contact_contactBookId_id_idx" ON "Contact"("contactBookId", "id");
-- CreateIndex
CREATE INDEX "Email_campaignId_contactId_idx" ON "Email"("campaignId", "contactId");
+44 -25
View File
@@ -263,9 +263,19 @@ model Email {
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
emailEvents EmailEvent[]
@@index([campaignId, contactId])
@@index([createdAt(sort: Desc)])
}
model CampaignEmail {
campaignId String
contactId String
emailId String
createdAt DateTime @default(now())
@@id([campaignId, contactId])
}
model EmailEvent {
id String @id @default(cuid())
emailId String
@@ -320,44 +330,53 @@ model Contact {
contactBook ContactBook @relation(fields: [contactBookId], references: [id], onDelete: Cascade)
@@unique([contactBookId, email])
@@index([contactBookId, id])
}
enum CampaignStatus {
DRAFT
SCHEDULED
RUNNING
PAUSED
SENT
}
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?
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)
status CampaignStatus @default(DRAFT)
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)
status CampaignStatus @default(DRAFT)
batchSize Int @default(500)
batchWindowMinutes Int @default(0)
lastCursor String?
lastSentAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
@@index([createdAt(sort: Desc)])
@@index([status, scheduledAt])
}
model Template {
@@ -41,6 +41,8 @@ import {
AccordionItem,
AccordionTrigger,
} from "@usesend/ui/src/accordion";
import ScheduleCampaign from "../../schedule-campaign";
import { useRouter } from "next/navigation";
const sendSchema = z.object({
confirmation: z.string(),
@@ -63,7 +65,7 @@ export default function EditCampaignPage({
{ campaignId },
{
enabled: !!campaignId,
},
}
);
if (isLoading) {
@@ -94,11 +96,12 @@ function CampaignEditor({
}: {
campaign: Campaign & { imageUploadSupported: boolean };
}) {
const router = useRouter();
const contactBooksQuery = api.contacts.getContactBooks.useQuery({});
const utils = api.useUtils();
const [json, setJson] = useState<Record<string, any> | undefined>(
campaign.content ? JSON.parse(campaign.content) : undefined,
campaign.content ? JSON.parse(campaign.content) : undefined
);
const [isSaving, setIsSaving] = useState(false);
const [name, setName] = useState(campaign.name);
@@ -106,12 +109,11 @@ function CampaignEditor({
const [from, setFrom] = useState(campaign.from);
const [contactBookId, setContactBookId] = useState(campaign.contactBookId);
const [replyTo, setReplyTo] = useState<string | undefined>(
campaign.replyTo[0],
campaign.replyTo[0]
);
const [previewText, setPreviewText] = useState<string | null>(
campaign.previewText,
campaign.previewText
);
const [openSendDialog, setOpenSendDialog] = useState(false);
const updateCampaignMutation = api.campaign.updateCampaign.useMutation({
onSuccess: () => {
@@ -119,13 +121,8 @@ function CampaignEditor({
setIsSaving(false);
},
});
const sendCampaignMutation = api.campaign.sendCampaign.useMutation();
const getUploadUrl = api.campaign.generateImagePresignedUrl.useMutation();
const sendForm = useForm<z.infer<typeof sendSchema>>({
resolver: zodResolver(sendSchema),
});
function updateEditorContent() {
updateCampaignMutation.mutate({
campaignId: campaign.id,
@@ -135,39 +132,13 @@ function CampaignEditor({
const deboucedUpdateCampaign = useDebouncedCallback(
updateEditorContent,
1000,
1000
);
async function onSendCampaign(values: z.infer<typeof sendSchema>) {
if (
values.confirmation?.toLocaleLowerCase() !== "Send".toLocaleLowerCase()
) {
sendForm.setError("confirmation", {
message: "Please type 'Send' to confirm",
});
return;
}
sendCampaignMutation.mutate(
{
campaignId: campaign.id,
},
{
onSuccess: () => {
setOpenSendDialog(false);
toast.success(`Campaign sent successfully`);
},
onError: (error) => {
toast.error(`Failed to send campaign: ${error.message}`);
},
},
);
}
const handleFileChange = async (file: File) => {
if (file.size > IMAGE_SIZE_LIMIT) {
throw new Error(
`File should be less than ${IMAGE_SIZE_LIMIT / 1024 / 1024}MB`,
`File should be less than ${IMAGE_SIZE_LIMIT / 1024 / 1024}MB`
);
}
@@ -191,10 +162,8 @@ function CampaignEditor({
return imageUrl;
};
const confirmation = sendForm.watch("confirmation");
const contactBook = contactBooksQuery.data?.find(
(book) => book.id === contactBookId,
(book) => book.id === contactBookId
);
return (
@@ -220,7 +189,7 @@ function CampaignEditor({
toast.error(`${e.message}. Reverting changes.`);
setName(campaign.name);
},
},
}
);
}}
/>
@@ -235,56 +204,13 @@ function CampaignEditor({
? "just now"
: `${formatDistanceToNow(campaign.updatedAt)} ago`}
</div>
<Dialog open={openSendDialog} onOpenChange={setOpenSendDialog}>
<DialogTrigger asChild>
<Button variant="default">Send Campaign</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Send Campaign</DialogTitle>
<DialogDescription>
Are you sure you want to send this campaign? This action
cannot be undone.
</DialogDescription>
</DialogHeader>
<div className="py-2">
<Form {...sendForm}>
<form
onSubmit={sendForm.handleSubmit(onSendCampaign)}
className="space-y-4"
>
<FormField
control={sendForm.control}
name="confirmation"
render={({ field }) => (
<FormItem>
<FormLabel>Type 'Send' to confirm</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end">
<Button
type="submit"
disabled={
sendCampaignMutation.isPending ||
confirmation?.toLocaleLowerCase() !==
"Send".toLocaleLowerCase()
}
>
{sendCampaignMutation.isPending
? "Sending..."
: "Send"}
</Button>
</div>
</form>
</Form>
</div>
</DialogContent>
</Dialog>
<ScheduleCampaign
campaign={campaign}
onScheduled={() => {
router.push(`/campaigns/${campaign.id}`);
}}
/>
</div>
</div>
@@ -315,7 +241,7 @@ function CampaignEditor({
toast.error(`${e.message}. Reverting changes.`);
setSubject(campaign.subject);
},
},
}
);
}}
className="mt-1 py-1 text-sm block w-full outline-none border-b border-transparent focus:border-border bg-transparent"
@@ -350,7 +276,7 @@ function CampaignEditor({
toast.error(`${e.message}. Reverting changes.`);
setFrom(campaign.from);
},
},
}
);
}}
/>
@@ -381,7 +307,7 @@ function CampaignEditor({
toast.error(`${e.message}. Reverting changes.`);
setReplyTo(campaign.replyTo[0]);
},
},
}
);
}}
/>
@@ -414,7 +340,7 @@ function CampaignEditor({
toast.error(`${e.message}. Reverting changes.`);
setPreviewText(campaign.previewText ?? "");
},
},
}
);
}}
className="mt-1 py-1 text-sm block w-full outline-none border-b border-transparent bg-transparent focus:border-border"
@@ -440,7 +366,7 @@ function CampaignEditor({
onError: () => {
setContactBookId(campaign.contactBookId);
},
},
}
);
setContactBookId(val);
}}
@@ -14,6 +14,14 @@ import { H2 } from "@usesend/ui";
import Spinner from "@usesend/ui/src/spinner";
import { api } from "~/trpc/react";
import { use } from "react";
import { CampaignStatus } from "@prisma/client";
import { formatDistanceToNow } from "date-fns";
import TogglePauseCampaign from "../toggle-pause-campaign";
import CampaignStatusBadge from "../../campaigns/campaign-status-badge";
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";
export default function CampaignDetailsPage({
params,
@@ -22,9 +30,31 @@ export default function CampaignDetailsPage({
}) {
const { campaignId } = use(params);
const { data: campaign, isLoading } = api.campaign.getCampaign.useQuery({
campaignId: campaignId,
});
const { data: campaign, isLoading } = api.campaign.getCampaign.useQuery(
{ campaignId: campaignId },
{
refetchInterval: (query) => {
const c: any = query.state.data;
if (!c) return false;
if (
c.status === CampaignStatus.RUNNING ||
c.status === CampaignStatus.PAUSED
) {
return 5000;
}
return false;
},
}
);
const { data: latestEmails, isLoading: latestEmailsLoading } =
api.campaign.latestEmails.useQuery(
{ campaignId: campaignId },
{
refetchInterval: 5000,
}
);
if (isLoading) {
return (
@@ -38,74 +68,181 @@ export default function CampaignDetailsPage({
return <div>Campaign not found</div>;
}
const statusCards = [
const deliveredCount = campaign.delivered ?? 0;
const openedCount = campaign.opened ?? 0;
const clickedCount = campaign.clicked ?? 0;
const unsubscribedCount = campaign.unsubscribed ?? 0;
const deliveredDenominator = deliveredCount > 0 ? deliveredCount : 0;
const percentageOfDelivered = (value: number) =>
deliveredDenominator > 0 ? (value / deliveredDenominator) * 100 : 0;
const statisticsRows = [
{
status: "delivered",
count: campaign.delivered,
percentage: 100,
count: deliveredCount,
percentage: deliveredDenominator > 0 ? 100 : 0,
},
{
status: "unsubscribed",
count: campaign.unsubscribed,
percentage: (campaign.unsubscribed / campaign.delivered) * 100,
count: unsubscribedCount,
percentage: percentageOfDelivered(unsubscribedCount),
},
{
status: "clicked",
count: campaign.clicked,
percentage: (campaign.clicked / campaign.delivered) * 100,
count: clickedCount,
percentage: percentageOfDelivered(clickedCount),
},
{
status: "opened",
count: campaign.opened,
percentage: (campaign.opened / campaign.delivered) * 100,
count: openedCount,
percentage: percentageOfDelivered(openedCount),
},
];
const total = campaign.total ?? 0;
const processed = campaign.sent ?? 0;
return (
<div className="container mx-auto">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href="/campaigns" className="text-lg">
Campaigns
</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator className="text-lg" />
<BreadcrumbItem>
<BreadcrumbPage className="text-lg ">
{campaign.name}
</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<div className="mt-10">
<H2 className="mb-4"> Statistics</H2>
<div className="flex gap-4">
{statusCards.map((card) => (
<div
key={card.status}
className="h-[100px] w-1/4 bg-secondary/10 border rounded-lg shadow p-4 flex flex-col gap-3"
>
<div className="flex items-center gap-3">
{card.status !== "total" ? (
<CampaignStatusBadge status={card.status} />
) : null}
<div className="capitalize">{card.status.toLowerCase()}</div>
</div>
<div className="flex justify-between items-end">
<div className="text-foreground font-light text-2xl font-mono">
{card.count}
<div className="flex justify-between items-center">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href="/campaigns" className="text-lg">
Campaigns
</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator className="text-lg" />
<BreadcrumbItem>
<BreadcrumbPage className="text-lg ">
<div className="flex items-center gap-2">
<div className="max-w-[300px] truncate">{campaign.name}</div>
<CampaignStatusBadge status={campaign.status} />
</div>
{card.status !== "total" ? (
<div className="text-sm pb-1">
{card.percentage.toFixed(1)}%
</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
{campaign.status === "SCHEDULED" ? (
<Link href={`/campaigns/${campaign.id}/edit`}>
<Button>Edit</Button>
</Link>
) : (
<TogglePauseCampaign campaign={campaign} mode="full" />
)}
</div>
<div className="mt-10">
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader className="space-y-4">
<div className="flex flex-col gap-1">
<CardTitle className="text-sm font-mono">Statistics</CardTitle>
{total > 0 ? (
<div className="text-sm text-muted-foreground font-mono">
{processed.toLocaleString()} of {total.toLocaleString()}{" "}
processed
</div>
) : null}
) : (
<div className="text-sm text-muted-foreground">
No recipients processed yet
</div>
)}
</div>
</div>
))}
</CardHeader>
<CardContent className="space-y-4">
{statisticsRows.map((row, index) => (
<div
key={row.status}
className={`flex items-center justify-between gap-4 px-0 pb-3 ${
index !== statisticsRows.length - 1
? "border-b border-dashed border-border"
: ""
}`}
>
<div className="flex items-center gap-3">
<CampaignStatusIndicator status={row.status} />
<div>
<div className="text-sm capitalize">{row.status}</div>
</div>
</div>
<div className="text-right">
<div className="text-xl font-mono">{row.count}</div>
{row.status !== "delivered" ? (
<div className="text-xs text-muted-foreground">
{row.percentage.toFixed(1)}% of delivered
</div>
) : null}
</div>
</div>
))}
</CardContent>
</Card>
<Card>
<CardHeader className="flex gap-2">
<CardTitle className="text-sm font-mono">Live activity</CardTitle>
</CardHeader>
<CardContent>
<div className="flex h-[300px] flex-col">
{latestEmailsLoading ? (
<div className="flex flex-1 items-center justify-center">
<Spinner className="h-5 w-5 text-foreground" />
</div>
) : !latestEmails || latestEmails.length === 0 ? (
<div className="flex flex-1 items-center justify-center">
<div className="rounded text-sm text-muted-foreground">
No recent user actions yet.
</div>
</div>
) : (
<div className="space-y-4 overflow-y-auto overscroll-y-contain pr-1 no-scrollbar">
<AnimatePresence initial={true}>
{latestEmails.map((email) => {
const recipients = email.to ?? [];
const primaryRecipient =
recipients.length > 0
? recipients[0]
: "Unknown recipient";
const timestamp =
email.latestStatus === "SCHEDULED" &&
email.scheduledAt
? new Date(email.scheduledAt)
: new Date(email.updatedAt ?? email.createdAt);
const relativeTime = formatDistanceToNow(timestamp, {
addSuffix: true,
});
return (
<motion.div
key={email.id}
layout
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
transition={{ duration: 0.2, ease: "easeOut" }}
className="flex flex-col gap-2 border-b pb-4 last:border-b-0 last:pb-0"
>
<div className="text-sm font-mono">
{primaryRecipient}
</div>
<div className="flex items-center justify-between gap-3">
<EmailStatusBadge status={email.latestStatus} />
<span className="whitespace-nowrap text-xs text-muted-foreground font-mono">
{relativeTime}
</span>
</div>
</motion.div>
);
})}
</AnimatePresence>
</div>
)}
</div>
</CardContent>
</Card>
</div>
</div>
@@ -146,43 +283,29 @@ export default function CampaignDetailsPage({
);
}
const CampaignStatusBadge: React.FC<{ status: string }> = ({ status }) => {
let outsideColor = "bg-gray";
let insideColor = "bg-gray/50";
const CampaignStatusIndicator: React.FC<{ status: string }> = ({ status }) => {
let colorClass = "bg-gray";
switch (status) {
case "delivered":
outsideColor = "bg-green/30";
insideColor = "bg-green";
colorClass = "bg-green";
break;
case "bounced":
case "unsubscribed":
outsideColor = "bg-red/30";
insideColor = "bg-red";
colorClass = "bg-red";
break;
case "clicked":
outsideColor = "bg-blue/30";
insideColor = "bg-blue";
colorClass = "bg-blue";
break;
case "opened":
outsideColor = "bg-purple/30";
insideColor = "bg-purple";
colorClass = "bg-purple";
break;
case "complained":
outsideColor = "bg-yellow/30";
insideColor = "bg-yellow";
colorClass = "bg-yellow";
break;
default:
outsideColor = "bg-gray/40";
insideColor = "bg-gray";
colorClass = "bg-gray";
}
return (
<div
className={`flex justify-center items-center p-1.5 ${outsideColor} rounded-full`}
>
<div className={`h-2 w-2 rounded-full ${insideColor}`}></div>
</div>
);
return <div className={`h-2.5 w-2.5 rounded-[2px] ${colorClass}`} />;
};
@@ -0,0 +1,146 @@
"use client";
import { CampaignStatus } from "@prisma/client";
import { format } from "date-fns";
import Link from "next/link";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@usesend/ui/src/tooltip";
import DeleteCampaign from "./delete-campaign";
import DuplicateCampaign from "./duplicate-campaign";
import TogglePauseCampaign from "./toggle-pause-campaign";
import CampaignStatusBadge from "./campaign-status-badge";
interface CampaignCardProps {
campaign: {
id: string;
name: string;
subject: string;
from: string;
status: CampaignStatus;
createdAt: Date;
updatedAt: Date;
scheduledAt?: Date | null;
total: number;
sent: number;
delivered: number;
unsubscribed: number;
};
}
export default function CampaignCard({ campaign }: CampaignCardProps) {
const sentPercentage =
campaign.total > 0 ? Math.round((campaign.sent / campaign.total) * 100) : 0;
const pendingCount = campaign.total - campaign.sent;
return (
<div className="border border-border rounded-xl p-4 shadow-sm hover:shadow-md transition-shadow">
{/* Header: Campaign name + status badge */}
<div className="flex items-center justify-between ">
<div className="w-1/3">
<Link
href={
campaign.status === CampaignStatus.DRAFT ||
campaign.status === CampaignStatus.SCHEDULED
? `/campaigns/${campaign.id}/edit`
: `/campaigns/${campaign.id}`
}
>
<div className="text-ellipsis text-sm font-medium underline decoration-dashed underline-offset-2">
{campaign.name}
</div>
</Link>
<div className="text-sm font-mono text-muted-foreground mt-2">
{campaign.status === CampaignStatus.SCHEDULED ? (
campaign.scheduledAt && (
<div className="">
At{" "}
<strong>
{format(new Date(campaign.scheduledAt), "MMM do, hh:mm a")}
</strong>
</div>
)
) : campaign.status === CampaignStatus.SENT ? (
<div className="flex items-center gap-2">
<span>
Delivered <strong>{campaign.delivered},</strong>
</span>
{/* <span className="text-muted-foreground/50 text-opacity-20">
|
</span> */}
<span>
Unsubscribed <strong>{campaign.unsubscribed}</strong>
</span>
</div>
) : (
<div className="flex items-center gap-2">
<span>
Sent <strong>{campaign.sent},</strong>
</span>
{pendingCount > 0 && (
<span>
Pending <strong>{pendingCount}</strong>
</span>
)}
</div>
)}
</div>
</div>
<CampaignStatusBadge status={campaign.status} />
{/* Actions */}
<TooltipProvider>
<div className="flex gap-4 items-center justify-end w-[150px]">
{(campaign.status === CampaignStatus.SCHEDULED ||
campaign.status === CampaignStatus.RUNNING ||
campaign.status === CampaignStatus.PAUSED) && (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<TogglePauseCampaign campaign={campaign} />
</span>
</TooltipTrigger>
<TooltipContent className="text-xs">
{campaign.status === CampaignStatus.PAUSED
? "Resume campaign"
: "Pause campaign"}
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<DuplicateCampaign campaign={campaign} />
</span>
</TooltipTrigger>
<TooltipContent className="text-xs">
Duplicate campaign
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<DeleteCampaign campaign={campaign} />
</span>
</TooltipTrigger>
<TooltipContent className="text-xs">
Delete campaign
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
</div>
{/* Scheduled date for scheduled campaigns */}
{/* Mini stats */}
</div>
);
}
@@ -1,43 +1,73 @@
"use client";
import {
Table,
TableHeader,
TableRow,
TableHead,
TableBody,
TableCell,
} from "@usesend/ui/src/table";
import { api } from "~/trpc/react";
import { useUrlState } from "~/hooks/useUrlState";
import { Button } from "@usesend/ui/src/button";
import Spinner from "@usesend/ui/src/spinner";
import { formatDistanceToNow } from "date-fns";
import { CampaignStatus } from "@prisma/client";
import DeleteCampaign from "./delete-campaign";
import Link from "next/link";
import DuplicateCampaign from "./duplicate-campaign";
import {
Select,
SelectTrigger,
SelectContent,
SelectItem,
} from "@usesend/ui/src/select";
import { Input } from "@usesend/ui/src/input";
import { Search } from "lucide-react";
import { useDebouncedCallback } from "use-debounce";
import CampaignCard from "./campaign-card";
export default function CampaignList() {
const [page, setPage] = useUrlState("page", "1");
const [status, setStatus] = useUrlState("status");
const [searchTerm, setSearchTerm] = useUrlState("search");
const [search, setSearch] = useUrlState("search");
const debouncedSearch = useDebouncedCallback((value: string) => {
setSearch(value);
}, 1000);
const onSearch = (value: string) => {
setSearchTerm(value);
debouncedSearch(value);
};
const pageNumber = Number(page);
const campaignsQuery = api.campaign.getCampaigns.useQuery({
page: pageNumber,
status: status as CampaignStatus | null,
});
const campaignsQuery = api.campaign.getCampaigns.useQuery(
{
page: pageNumber,
status: status as CampaignStatus | null,
search,
},
{
refetchInterval: (query) => {
const c = query.state.data?.campaigns;
if (!c) return false;
const shouldPoll = c.some(
(campaign) =>
campaign.status === CampaignStatus.RUNNING ||
campaign.status === CampaignStatus.SCHEDULED
);
return shouldPoll ? 5000 : false;
},
}
);
return (
<div className="mt-10 flex flex-col gap-4">
<div className="flex justify-end">
<div className="flex flex-col sm:flex-row gap-4 justify-between">
{/* Search input */}
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
<Input
placeholder="Search campaigns..."
value={searchTerm || ""}
onChange={(e) => onSearch(e.target.value)}
className="pl-10"
/>
</div>
{/* Status filter */}
<Select
value={status ?? "all"}
onValueChange={(val) => setStatus(val === "all" ? null : val)}
@@ -58,82 +88,38 @@ export default function CampaignList() {
>
Scheduled
</SelectItem>
<SelectItem value={CampaignStatus.RUNNING} className=" capitalize">
Running
</SelectItem>
<SelectItem value={CampaignStatus.PAUSED} className=" capitalize">
Paused
</SelectItem>
<SelectItem value={CampaignStatus.SENT} className=" capitalize">
Sent
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col rounded-xl border border-border shadow">
<Table className="">
<TableHeader className="">
<TableRow className=" bg-muted/30">
<TableHead className="rounded-tl-xl">Name</TableHead>
<TableHead>Status</TableHead>
<TableHead className="">Created At</TableHead>
<TableHead className="rounded-tr-xl">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{campaignsQuery.isLoading ? (
<TableRow className="h-32">
<TableCell colSpan={4} className="text-center py-4">
<Spinner
className="w-6 h-6 mx-auto"
innerSvgClass="stroke-primary"
/>
</TableCell>
</TableRow>
) : campaignsQuery.data?.campaigns.length ? (
campaignsQuery.data?.campaigns.map((campaign) => (
<TableRow key={campaign.id} className="">
<TableCell className="font-medium">
<Link
className="underline underline-offset-4 decoration-dashed text-foreground hover:text-foreground"
href={
campaign.status === CampaignStatus.DRAFT
? `/campaigns/${campaign.id}/edit`
: `/campaigns/${campaign.id}`
}
>
{campaign.name}
</Link>
</TableCell>
<TableCell>
<div
className={`text-center w-[130px] rounded capitalize py-1 text-xs ${
campaign.status === CampaignStatus.DRAFT
? "bg-gray/15 text-gray border border-gray/25"
: campaign.status === CampaignStatus.SENT
? "bg-green/15 text-green border border-green/25"
: "bg-yellow/15 text-yellow border border-yellow/25"
}`}
>
{campaign.status.toLowerCase()}
</div>
</TableCell>
<TableCell className="">
{formatDistanceToNow(new Date(campaign.createdAt), {
addSuffix: true,
})}
</TableCell>
<TableCell>
<div className="flex gap-2">
<DuplicateCampaign campaign={campaign} />
<DeleteCampaign campaign={campaign} />
</div>
</TableCell>
</TableRow>
))
) : (
<TableRow className="h-32">
<TableCell colSpan={4} className="text-center py-4">
No campaigns found
</TableCell>
</TableRow>
{/* Campaign cards */}
<div className="flex flex-col gap-8">
{campaignsQuery.isLoading ? (
<div className="flex justify-center py-12">
<Spinner className="w-6 h-6" innerSvgClass="stroke-primary" />
</div>
) : campaignsQuery.data?.campaigns.length ? (
campaignsQuery.data?.campaigns.map((campaign) => (
<CampaignCard key={campaign.id} campaign={campaign} />
))
) : (
<div className="text-center py-12 text-muted-foreground">
No campaigns found
{(search || status) && (
<div className="text-sm mt-2">
Try adjusting your search or filters
</div>
)}
</TableBody>
</Table>
</div>
)}
</div>
<div className="flex gap-4 justify-end">
<Button
@@ -0,0 +1,36 @@
import { CampaignStatus } from "@prisma/client";
interface CampaignStatusBadgeProps {
status: CampaignStatus;
}
export default function CampaignStatusBadge({
status,
}: CampaignStatusBadgeProps) {
const getStatusColor = (status: CampaignStatus) => {
switch (status) {
case CampaignStatus.DRAFT:
return "bg-gray/15 text-gray border border-gray/20";
case CampaignStatus.SENT:
return "bg-green/15 text-green border border-green/20";
case CampaignStatus.RUNNING:
return "bg-blue/15 text-blue border border-blue/20";
case CampaignStatus.PAUSED:
return "bg-yellow/15 text-yellow border border-yellow/20";
case CampaignStatus.SCHEDULED:
return "bg-gray/15 text-gray border border-gray/20";
default:
return "bg-gray/15 text-gray border border-gray/20";
}
};
return (
<div
className={`text-center min-w-[110px] rounded capitalize py-1 px-3 text-xs ${getStatusColor(
status,
)}`}
>
{status.toLowerCase()}
</div>
);
}
@@ -0,0 +1,314 @@
"use client";
import { Button } from "@usesend/ui/src/button";
import { Input } from "@usesend/ui/src/input";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@usesend/ui/src/dialog";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@usesend/ui/src/popover";
import * as chrono from "chrono-node";
import { api } from "~/trpc/react";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "@usesend/ui/src/toaster";
import { Calendar as CalendarIcon } from "lucide-react";
import { Calendar } from "@usesend/ui/src/calendar";
import { Campaign } from "@prisma/client";
import { format } from "date-fns";
import { Spinner } from "@usesend/ui/src/spinner";
export const ScheduleCampaign: React.FC<{
campaign: Partial<Campaign> & { id: string };
onScheduled?: () => void;
}> = ({ campaign, onScheduled }) => {
const initialScheduledAtDate = campaign.scheduledAt
? new Date(campaign.scheduledAt)
: null;
const [open, setOpen] = useState(false);
const [scheduleInput, setScheduleInput] = useState<string>(
initialScheduledAtDate
? format(initialScheduledAtDate, "yyyy-MM-dd HH:mm")
: ""
);
const [selectedDate, setSelectedDate] = useState<Date | null>(
initialScheduledAtDate ?? new Date()
);
const [isConfirmNow, setIsConfirmNow] = useState(false);
const [error, setError] = useState<string | null>(null);
const scheduleMutation = api.campaign.scheduleCampaign.useMutation();
const utils = api.useUtils();
const scheduledAtTimestamp = campaign.scheduledAt
? new Date(campaign.scheduledAt).getTime()
: null;
useEffect(() => {
if (!open) return;
if (scheduledAtTimestamp != null) {
const scheduledDate = new Date(scheduledAtTimestamp);
setSelectedDate(scheduledDate);
setScheduleInput(format(scheduledDate, "yyyy-MM-dd HH:mm"));
return;
}
const now = new Date();
setSelectedDate(now);
setScheduleInput("");
}, [open, scheduledAtTimestamp]);
const onSchedule = (scheduledAt?: Date) => {
if (error) setError(null);
scheduleMutation.mutate(
{
campaignId: campaign.id,
// Never send free text to backend; only a Date
scheduledAt: scheduledAt ? scheduledAt : undefined,
},
{
onSuccess: () => {
utils.campaign.getCampaigns.invalidate();
utils.campaign.getCampaign.invalidate({ campaignId: campaign.id });
setOpen(false);
setScheduleInput("");
setSelectedDate(null);
setIsConfirmNow(false);
setError(null);
toast.success("Campaign scheduled");
onScheduled?.();
},
onError: (error) => {
setError(error.message || "Failed to schedule campaign");
},
}
);
};
const onDialogSchedule = () => {
const parsed = selectedDate ?? chrono.parseDate(scheduleInput);
if (!parsed) {
setError("Invalid date and time");
return;
}
onSchedule(parsed);
};
const onScheduleInputChange = (input: string) => {
setScheduleInput(input);
if (error) setError(null);
const parsed = chrono.parseDate(input);
if (parsed) {
setSelectedDate(parsed);
} else {
setSelectedDate(new Date());
}
};
// Generate 15-minute time slots from 12:00 AM to 11:45 PM
const timeOptions = useMemo(() => {
const options: { minutes: number; label: string }[] = [];
const base = new Date();
base.setHours(0, 0, 0, 0);
for (let m = 0; m < 24 * 60; m += 15) {
const d = new Date(base);
d.setMinutes(m);
options.push({ minutes: m, label: format(d, "h:mm a") });
}
return options;
}, []);
const getMinutesOfDay = (d: Date) => d.getHours() * 60 + d.getMinutes();
const formatForInput = (d: Date) => format(d, "yyyy-MM-dd HH:mm");
const setDatePreserveTime = (dateOnly: Date) => {
const current = selectedDate ?? new Date();
const updated = new Date(dateOnly);
updated.setHours(current.getHours(), current.getMinutes(), 0, 0);
setSelectedDate(updated);
setScheduleInput(formatForInput(updated));
};
const setTimePreserveDate = (minutesFromMidnight: number) => {
const base = selectedDate ?? new Date();
const hours = Math.floor(minutesFromMidnight / 60);
const minutes = minutesFromMidnight % 60;
const updated = new Date(base);
updated.setHours(hours, minutes, 0, 0);
setSelectedDate(updated);
setScheduleInput(formatForInput(updated));
};
const dialogContentRef = useRef<HTMLDivElement | null>(null);
return (
<div className="flex items-center gap-2">
<Dialog
open={open}
onOpenChange={(_open) => {
if (_open !== open) {
setOpen(_open);
if (!_open) setError(null);
}
}}
>
<DialogTrigger asChild>
<Button variant="default">Schedule Campaign</Button>
</DialogTrigger>
<DialogContent ref={dialogContentRef}>
<DialogHeader>
<DialogTitle>Schedule Campaign</DialogTitle>
</DialogHeader>
<div className="py-2 space-y-8">
<div>
<label htmlFor="scheduledAt" className="block mb-2">
Schedule at
</label>
<div className="relative">
<Input
id="scheduledAt"
placeholder="e.g., tomorrow 9am, next monday 10:30"
value={scheduleInput}
onChange={(e) => onScheduleInputChange(e.target.value)}
/>
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground"
aria-label="Open date picker"
>
<CalendarIcon className="h-4 w-4" />
</button>
</PopoverTrigger>
<PopoverContent
className="w-[420px]"
align="end"
container={dialogContentRef.current}
>
<label className="block text-sm mb-2">
Pick date & time
</label>
<div className="flex gap-4 items-start">
<Calendar
mode="single"
selected={selectedDate ?? new Date()}
onSelect={(d) => {
if (d) setDatePreserveTime(d);
}}
className="rounded-md border w-[250px] h-[300px] shrink-0 font-mono"
/>
<div
className="h-[300px] overflow-y-auto no-scrollbar overscroll-contain rounded-md border p-1 w-[140px] min-h-0 font-mono"
onWheelCapture={(e) => {
e.stopPropagation();
}}
onTouchMoveCapture={(e) => {
e.stopPropagation();
}}
>
{timeOptions.map((opt) => {
const isActive = selectedDate
? getMinutesOfDay(selectedDate) === opt.minutes
: false;
return (
<button
key={opt.minutes}
type="button"
onClick={() => setTimePreserveDate(opt.minutes)}
className={
"w-full text-left text-sm px-2 py-1 rounded hover:bg-accent hover:text-accent-foreground " +
(isActive
? " bg-accent text-accent-foreground"
: "")
}
>
{opt.label}
</button>
);
})}
</div>
</div>
</PopoverContent>
</Popover>
</div>
<div className="font-mono mt-4 rounded p-2 text-primary border border-border text-sm">
{selectedDate ? (
<span className="">
{format(selectedDate, "MMMM do, h:mm a")}
</span>
) : (
<span className="">No date selected</span>
)}
</div>
</div>
{error && (
<div className="text-sm text-destructive bg-destructive/10 p-3 rounded-md border border-destructive">
{error}
</div>
)}
<div className="flex justify-end gap-4 items-center ">
{isConfirmNow ? (
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
Are you sure you want to send this campaign now?
</span>
<Button
size="sm"
onClick={() => {
onSchedule(new Date());
}}
disabled={scheduleMutation.isPending}
>
Yes
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setIsConfirmNow(false)}
>
Cancel
</Button>
</div>
) : (
<>
<Button
variant="outline"
onClick={() => {
setIsConfirmNow(true);
}}
disabled={scheduleMutation.isPending}
>
Send Now
</Button>
<Button
className="w-[130px]"
onClick={() => {
onDialogSchedule();
}}
isLoading={scheduleMutation.isPending}
showSpinner={true}
>
{scheduleMutation.isPending ? "Scheduling" : "Schedule"}
</Button>
</>
)}
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
};
export default ScheduleCampaign;
@@ -0,0 +1,97 @@
"use client";
import { Button } from "@usesend/ui/src/button";
import { api } from "~/trpc/react";
import React from "react";
import { Pause, Play } from "lucide-react";
import { Campaign, CampaignStatus } from "@prisma/client";
import { toast } from "@usesend/ui/src/toaster";
export const TogglePauseCampaign: React.FC<{
campaign: Partial<Campaign> & { id: string; status?: CampaignStatus };
mode?: "icon" | "full";
}> = ({ campaign, mode = "icon" }) => {
const utils = api.useUtils();
const pauseMutation = api.campaign.pauseCampaign.useMutation();
const resumeMutation = api.campaign.resumeCampaign.useMutation();
const isPaused = campaign.status === CampaignStatus.PAUSED;
const onToggle = () => {
if (isPaused) {
resumeMutation.mutate(
{ campaignId: campaign.id },
{
onSuccess: () => {
utils.campaign.getCampaigns.invalidate();
utils.campaign.getCampaign.invalidate();
toast.success("Campaign resumed");
},
}
);
} else {
pauseMutation.mutate(
{ campaignId: campaign.id },
{
onSuccess: () => {
utils.campaign.getCampaigns.invalidate();
utils.campaign.getCampaign.invalidate();
toast.success("Campaign paused");
},
}
);
}
};
const pending = pauseMutation.isPending || resumeMutation.isPending;
if (
campaign.status !== CampaignStatus.PAUSED &&
campaign.status !== CampaignStatus.RUNNING
) {
return null;
}
return (
<>
{mode === "icon" ? (
<Button
variant="ghost"
size="sm"
className="p-0 hover:bg-transparent"
onClick={onToggle}
disabled={pending}
title={isPaused ? "Resume" : "Pause"}
>
{isPaused ? (
<Play className="h-[18px] w-[18px] text-green/80" />
) : (
<Pause className="h-[18px] w-[18px] text-orange/80" />
)}
</Button>
) : (
<Button
variant="default"
className="gap-2 border-primary"
onClick={onToggle}
disabled={pending}
title={isPaused ? "Resume" : "Pause"}
>
{isPaused ? (
<>
<Play className="h-[18px] w-[18px]" />
<span>Resume</span>
</>
) : (
<>
<Pause className="h-[18px] w-[18px] " />
<span>Pause</span>
</>
)}
</Button>
)}
</>
);
};
export default TogglePauseCampaign;
+5
View File
@@ -25,6 +25,11 @@ export async function register() {
await import("~/server/jobs/usage-job");
}
const { CampaignSchedulerService } = await import(
"~/server/jobs/campaign-scheduler-job"
);
await CampaignSchedulerService.start();
initialized = true;
}
}
+96 -38
View File
@@ -11,10 +11,7 @@ import {
} from "~/server/api/trpc";
import { logger } from "~/server/logger/log";
import { nanoid } from "~/server/nanoid";
import {
sendCampaign,
subscribeContact,
} from "~/server/service/campaign-service";
import * as campaignService from "~/server/service/campaign-service";
import { validateDomainFromEmail } from "~/server/service/domain-service";
import {
getDocumentUploadUrl,
@@ -29,10 +26,10 @@ export const campaignRouter = createTRPCRouter({
z.object({
page: z.number().optional(),
status: z.enum(statuses).optional().nullable(),
}),
search: z.string().optional().nullable(),
})
)
.query(async ({ ctx: { db, team }, input }) => {
let completeTime = performance.now();
const page = input.page || 1;
const limit = 30;
const offset = (page - 1) * limit;
@@ -45,6 +42,23 @@ export const campaignRouter = createTRPCRouter({
whereConditions.status = input.status;
}
if (input.search) {
whereConditions.OR = [
{
name: {
contains: input.search,
mode: "insensitive",
},
},
{
subject: {
contains: input.search,
mode: "insensitive",
},
},
];
}
const countP = db.campaign.count({ where: whereConditions });
const campaignsP = db.campaign.findMany({
@@ -57,6 +71,11 @@ export const campaignRouter = createTRPCRouter({
createdAt: true,
updatedAt: true,
status: true,
scheduledAt: true,
total: true,
sent: true,
delivered: true,
unsubscribed: true,
},
orderBy: {
createdAt: "desc",
@@ -64,19 +83,8 @@ export const campaignRouter = createTRPCRouter({
skip: offset,
take: limit,
});
let time = performance.now();
campaignsP.then((campaigns) => {
logger.info(
`Time taken to get campaigns: ${performance.now() - time} milliseconds`,
);
});
const [campaigns, count] = await Promise.all([campaignsP, countP]);
logger.info(
{ duration: performance.now() - completeTime },
`Time taken to complete request`,
);
return { campaigns, totalPage: Math.ceil(count / limit) };
}),
@@ -87,7 +95,7 @@ export const campaignRouter = createTRPCRouter({
name: z.string(),
from: z.string(),
subject: z.string(),
}),
})
)
.mutation(async ({ ctx: { db, team }, input }) => {
const domain = await validateDomainFromEmail(input.from, team.id);
@@ -113,7 +121,7 @@ export const campaignRouter = createTRPCRouter({
content: z.string().optional(),
contactBookId: z.string().optional(),
replyTo: z.string().array().optional(),
}),
})
)
.mutation(async ({ ctx: { db, team, campaign: campaignOld }, input }) => {
const { campaignId, ...data } = input;
@@ -155,14 +163,9 @@ export const campaignRouter = createTRPCRouter({
return campaign;
}),
deleteCampaign: campaignProcedure.mutation(
async ({ ctx: { db, team }, input }) => {
const campaign = await db.campaign.delete({
where: { id: input.campaignId, teamId: team.id },
});
return campaign;
},
),
deleteCampaign: campaignProcedure.mutation(async ({ input }) => {
return await campaignService.deleteCampaign(input.campaignId);
}),
getCampaign: campaignProcedure.query(async ({ ctx: { db, team }, input }) => {
const campaign = await db.campaign.findUnique({
@@ -191,10 +194,31 @@ export const campaignRouter = createTRPCRouter({
};
}),
sendCampaign: campaignProcedure.mutation(
async ({ ctx: { db, team }, input }) => {
await sendCampaign(input.campaignId);
},
latestEmails: campaignProcedure.query(
async ({ ctx: { db, team, campaign } }) => {
const emails = await db.email.findMany({
where: {
teamId: team.id,
campaignId: campaign.id,
},
orderBy: [
{ updatedAt: "desc" },
{ createdAt: "desc" },
],
take: 10,
select: {
id: true,
subject: true,
to: true,
latestStatus: true,
createdAt: true,
updatedAt: true,
scheduledAt: true,
},
});
return emails;
}
),
reSubscribeContact: publicProcedure
@@ -202,14 +226,14 @@ export const campaignRouter = createTRPCRouter({
z.object({
id: z.string(),
hash: z.string(),
}),
})
)
.mutation(async ({ ctx: { db }, input }) => {
await subscribeContact(input.id, input.hash);
.mutation(async ({ input }) => {
await campaignService.subscribeContact(input.id, input.hash);
}),
duplicateCampaign: campaignProcedure.mutation(
async ({ ctx: { db, team, campaign }, input }) => {
async ({ ctx: { db, team, campaign } }) => {
const newCampaign = await db.campaign.create({
data: {
name: `${campaign.name} (Copy)`,
@@ -223,15 +247,49 @@ export const campaignRouter = createTRPCRouter({
});
return newCampaign;
},
}
),
scheduleCampaign: campaignProcedure
.input(
z.object({
campaignId: z.string(),
scheduledAt: z.union([z.string().datetime(), z.date()]).optional(),
batchSize: z.number().min(1).max(100_000).optional(),
})
)
.mutation(async ({ ctx: { team }, input }) => {
await campaignService.scheduleCampaign({
campaignId: input.campaignId,
teamId: team.id,
scheduledAt: input.scheduledAt,
batchSize: input.batchSize,
});
return { ok: true };
}),
pauseCampaign: campaignProcedure.mutation(async ({ ctx: { campaign } }) => {
await campaignService.pauseCampaign({
campaignId: campaign.id,
teamId: campaign.teamId,
});
return { ok: true };
}),
resumeCampaign: campaignProcedure.mutation(async ({ ctx: { campaign } }) => {
await campaignService.resumeCampaign({
campaignId: campaign.id,
teamId: campaign.teamId,
});
return { ok: true };
}),
generateImagePresignedUrl: campaignProcedure
.input(
z.object({
name: z.string(),
type: z.string(),
}),
})
)
.mutation(async ({ ctx: { team }, input }) => {
const extension = input.name.split(".").pop();
@@ -239,7 +297,7 @@ export const campaignRouter = createTRPCRouter({
const url = await getDocumentUploadUrl(
`${team.id}/${randomName}`,
input.type,
input.type
);
const imageUrl = `${env.S3_COMPATIBLE_PUBLIC_URL}/${team.id}/${randomName}`;
@@ -0,0 +1,104 @@
import { Queue, Worker } from "bullmq";
import { createWorkerHandler, TeamJob } from "../queue/bullmq-context";
import {
CAMPAIGN_SCHEDULER_QUEUE,
DEFAULT_QUEUE_OPTIONS,
} from "../queue/queue-constants";
import { getRedis } from "../redis";
import { CampaignBatchService } from "../service/campaign-service";
import { db } from "../db";
import { logger } from "../logger/log";
const SCHEDULER_TICK_MS = 1500;
type SchedulerJob = TeamJob<{}>;
export class CampaignSchedulerService {
private static schedulerQueue = new Queue<SchedulerJob>(
CAMPAIGN_SCHEDULER_QUEUE,
{
connection: getRedis(),
}
);
static worker = new Worker(
CAMPAIGN_SCHEDULER_QUEUE,
createWorkerHandler(async (_job: SchedulerJob) => {
try {
const now = new Date();
const campaigns = await db.campaign.findMany({
where: {
status: { in: ["SCHEDULED", "RUNNING"] },
OR: [{ scheduledAt: null }, { scheduledAt: { lte: now } }],
},
select: {
id: true,
teamId: true,
lastSentAt: true,
batchWindowMinutes: true,
},
});
const enqueuePromises: Promise<any>[] = [];
for (const c of campaigns) {
const windowMin = c.batchWindowMinutes ?? 0;
if (windowMin > 0 && c.lastSentAt) {
const elapsedMs = now.getTime() - new Date(c.lastSentAt).getTime();
const windowMs = windowMin * 60 * 1000;
if (elapsedMs < windowMs) {
const remainingMs = windowMs - elapsedMs;
logger.debug(
{ campaignId: c.id, remainingMs, windowMs },
"Skip queueing batch; window not elapsed"
);
continue;
}
}
enqueuePromises.push(
CampaignBatchService.queueBatch({
campaignId: c.id,
teamId: c.teamId,
}).catch((err) => {
logger.error(
{ err, campaignId: c.id },
"Failed to enqueue campaign batch"
);
})
);
}
if (enqueuePromises.length > 0) {
const results = await Promise.allSettled(enqueuePromises);
const rejected = results.filter(
(r) => r.status === "rejected"
).length;
const fulfilled = results.length - rejected;
logger.debug(
{ total: results.length, fulfilled, rejected },
"Scheduler enqueue summary"
);
}
} catch (err) {
logger.error({ err }, "Campaign scheduler tick failed");
}
}),
{ connection: getRedis(), concurrency: 1 }
);
static async start() {
try {
await this.schedulerQueue.add(
"tick",
{},
{
jobId: "campaign-scheduler",
repeat: { every: SCHEDULER_TICK_MS },
...DEFAULT_QUEUE_OPTIONS,
}
);
} catch (err) {
// Adding the same repeatable job is idempotent; ignore job-exists errors
logger.info({ err }, "Scheduler start attempted");
}
}
}
@@ -1,6 +1,8 @@
export const SES_WEBHOOK_QUEUE = "ses-webhook";
export const CAMPAIGN_MAIL_PROCESSING_QUEUE = "campaign-emails-processing";
export const CONTACT_BULK_ADD_QUEUE = "contact-bulk-add";
export const CAMPAIGN_BATCH_QUEUE = "campaign-batch";
export const CAMPAIGN_SCHEDULER_QUEUE = "campaign-scheduler";
export const DEFAULT_QUEUE_OPTIONS = {
removeOnComplete: true,
+367 -123
View File
@@ -8,17 +8,17 @@ import {
EmailStatus,
UnsubscribeReason,
} from "@prisma/client";
import { validateDomainFromEmail } from "./domain-service";
import { EmailQueueService } from "./email-queue-service";
import { Queue, Worker } from "bullmq";
import { getRedis } from "../redis";
import {
CAMPAIGN_MAIL_PROCESSING_QUEUE,
CAMPAIGN_BATCH_QUEUE,
DEFAULT_QUEUE_OPTIONS,
} from "../queue/queue-constants";
import { logger } from "../logger/log";
import { createWorkerHandler, TeamJob } from "../queue/bullmq-context";
import { SuppressionService } from "./suppression-service";
import { UnsendApiError } from "../public-api/api-error";
const CAMPAIGN_UNSUB_PLACEHOLDER_TOKENS = [
"{{unsend_unsubscribe_url}}",
@@ -57,21 +57,6 @@ export async function sendCampaign(id: string) {
throw new Error("No contact book found for campaign");
}
const contactBook = await db.contactBook.findUnique({
where: { id: campaign.contactBookId },
include: {
contacts: {
where: {
subscribed: true,
},
},
},
});
if (!contactBook) {
throw new Error("Contact book not found");
}
if (!campaign.html) {
throw new Error("No HTML content for campaign");
}
@@ -83,27 +68,194 @@ export async function sendCampaign(id: string) {
);
if (!unsubPlaceholderFound) {
throw new Error(
"Campaign must include an unsubscribe link before sending"
);
throw new Error("Campaign must include an unsubscribe link before sending");
}
await sendCampaignEmail(campaign, {
campaignId: campaign.id,
from: campaign.from,
subject: campaign.subject,
html: campaign.html,
replyTo: campaign.replyTo,
cc: campaign.cc,
bcc: campaign.bcc,
teamId: campaign.teamId,
contacts: contactBook.contacts,
// Count subscribed contacts for total, don't load all into memory
const total = await db.contact.count({
where: { contactBookId: campaign.contactBookId, subscribed: true },
});
// Mark as scheduled (or keep running if already running), set totals and scheduledAt if not set
await db.campaign.update({
where: { id },
data: { status: "SENT", total: contactBook.contacts.length },
data: {
status: "SCHEDULED",
total,
scheduledAt: campaign.scheduledAt ?? new Date(),
lastCursor: campaign.lastCursor ?? null,
},
});
// Kick off first batch immediately (idempotent by jobId)
await CampaignBatchService.queueBatch({
campaignId: id,
teamId: campaign.teamId,
});
}
export async function scheduleCampaign({
campaignId,
teamId,
scheduledAt: scheduledAtInput,
batchSize,
}: {
campaignId: string;
teamId: number;
scheduledAt?: Date | string;
batchSize?: number;
}) {
let campaign = await db.campaign.findUnique({
where: { id: campaignId, teamId },
});
if (!campaign) {
throw new UnsendApiError({
code: "NOT_FOUND",
message: "Campaign not found",
});
}
if (!campaign.content) {
throw new UnsendApiError({
code: "BAD_REQUEST",
message: "No content added for campaign",
});
}
// Parse & render HTML (idempotent) similar to sendCampaign
try {
const jsonContent = JSON.parse(campaign.content);
const renderer = new EmailRenderer(jsonContent);
const html = await renderer.render();
campaign = await db.campaign.update({
where: { id: campaign.id },
data: { html },
});
} catch (err) {
throw new UnsendApiError({
code: "BAD_REQUEST",
message: "Invalid content",
});
}
if (!campaign.contactBookId) {
throw new UnsendApiError({
code: "BAD_REQUEST",
message: "No contact book found for campaign",
});
}
if (!campaign.html) {
throw new UnsendApiError({
code: "BAD_REQUEST",
message: "No HTML content for campaign",
});
}
const unsubPlaceholderFound = CAMPAIGN_UNSUB_PLACEHOLDER_TOKENS.some(
(placeholder) =>
campaign.content?.includes(placeholder) ||
campaign.html?.includes(placeholder)
);
if (!unsubPlaceholderFound) {
throw new UnsendApiError({
code: "BAD_REQUEST",
message: "Campaign must include an unsubscribe link before scheduling",
});
}
// Count subscribed contacts for total
const total = await db.contact.count({
where: { contactBookId: campaign.contactBookId, subscribed: true },
});
if (total === 0) {
throw new UnsendApiError({
code: "BAD_REQUEST",
message: "No subscribed contacts to send",
});
}
const scheduledAt = scheduledAtInput
? scheduledAtInput instanceof Date
? scheduledAtInput
: new Date(scheduledAtInput)
: new Date();
const shouldResetCursor =
campaign.status === "DRAFT" || campaign.status === "SENT";
await db.campaign.update({
where: { id: campaign.id },
data: {
status: "SCHEDULED",
scheduledAt,
total,
...(batchSize ? { batchSize } : {}),
...(shouldResetCursor ? { lastCursor: null } : {}),
},
});
return { ok: true };
}
export async function pauseCampaign({
campaignId,
teamId,
}: {
campaignId: string;
teamId: number;
}) {
const campaign = await db.campaign.findUnique({
where: { id: campaignId, teamId },
});
if (!campaign) {
throw new UnsendApiError({
code: "NOT_FOUND",
message: "Campaign not found",
});
}
await db.campaign.update({
where: { id: campaignId },
data: { status: "PAUSED" },
});
return { ok: true };
}
export async function resumeCampaign({
campaignId,
teamId,
}: {
campaignId: string;
teamId: number;
}) {
const campaign = await db.campaign.findUnique({
where: { id: campaignId, teamId },
});
if (!campaign) {
throw new UnsendApiError({
code: "NOT_FOUND",
message: "Campaign not found",
});
}
if (campaign.scheduledAt && campaign.scheduledAt.getTime() > Date.now()) {
await db.campaign.update({
where: { id: campaignId },
data: { status: "SCHEDULED" },
});
} else {
await db.campaign.update({
where: { id: campaignId },
data: { status: "RUNNING" },
});
}
return { ok: true };
}
export function createUnsubUrl(contactId: string, campaignId: string) {
@@ -242,18 +394,21 @@ export async function subscribeContact(id: string, hash: string) {
}
}
type CampainEmail = {
campaignId: string;
from: string;
subject: string;
html: string;
previewText?: string;
replyTo?: string[];
cc?: string[];
bcc?: string[];
teamId: number;
contacts: Array<Contact>;
};
export async function deleteCampaign(id: string) {
const campaign = await db.$transaction(async (tx) => {
await tx.campaignEmail.deleteMany({
where: { campaignId: id },
});
const campaign = await tx.campaign.delete({
where: { id },
});
return campaign;
});
return campaign;
}
type CampaignEmailJob = {
contact: Contact;
@@ -272,8 +427,6 @@ type CampaignEmailJob = {
};
};
type QueueCampaignEmailJob = TeamJob<CampaignEmailJob>;
async function processContactEmail(jobData: CampaignEmailJob) {
const { contact, campaign, emailConfig } = jobData;
const jsonContent = JSON.parse(campaign.content || "{}");
@@ -367,6 +520,18 @@ async function processContactEmail(jobData: CampaignEmailJob) {
},
});
try {
await db.campaignEmail.create({
data: {
campaignId: emailConfig.campaignId,
contactId: contact.id,
emailId: email.id,
},
});
} catch (error) {
logger.error({ err: error }, "Failed to create campaign email record");
}
return;
}
@@ -413,6 +578,22 @@ async function processContactEmail(jobData: CampaignEmailJob) {
},
});
try {
await db.campaignEmail.create({
data: {
campaignId: emailConfig.campaignId,
contactId: contact.id,
emailId: email.id,
},
});
} catch (error) {
logger.error(
{ err: error },
"Failed to create campaign email record so skipping email sending"
);
return;
}
// Queue email for sending
await EmailQueueService.queueEmail(
email.id,
@@ -423,50 +604,6 @@ async function processContactEmail(jobData: CampaignEmailJob) {
);
}
export async function sendCampaignEmail(
campaign: Campaign,
emailData: CampainEmail
) {
const {
campaignId,
from,
subject,
replyTo,
cc,
bcc,
teamId,
contacts,
previewText,
} = emailData;
const domain = await validateDomainFromEmail(from, teamId);
logger.info("Bulk queueing contacts");
await CampaignEmailService.queueBulkContacts(
contacts.map((contact) => ({
contact,
campaign,
emailConfig: {
from,
subject,
replyTo: replyTo
? Array.isArray(replyTo)
? replyTo
: [replyTo]
: undefined,
cc: cc ? (Array.isArray(cc) ? cc : [cc]) : undefined,
bcc: bcc ? (Array.isArray(bcc) ? bcc : [bcc]) : undefined,
teamId,
campaignId,
previewText,
domainId: domain.id,
region: domain.region,
},
}))
);
}
export async function updateCampaignAnalytics(
campaignId: string,
emailStatus: EmailStatus,
@@ -514,51 +651,158 @@ export async function updateCampaignAnalytics(
});
}
const CAMPAIGN_EMAIL_CONCURRENCY = 50;
// ---------------------------
// Simple campaign batch queue
// ---------------------------
class CampaignEmailService {
private static campaignQueue = new Queue<QueueCampaignEmailJob>(
CAMPAIGN_MAIL_PROCESSING_QUEUE,
type CampaignBatchJob = TeamJob<{ campaignId: string }>;
export class CampaignBatchService {
private static batchQueue = new Queue<CampaignBatchJob>(
CAMPAIGN_BATCH_QUEUE,
{
connection: getRedis(),
}
);
// TODO: Add team context to job data when queueing
static worker = new Worker(
CAMPAIGN_MAIL_PROCESSING_QUEUE,
createWorkerHandler(async (job: QueueCampaignEmailJob) => {
await processContactEmail(job.data);
CAMPAIGN_BATCH_QUEUE,
createWorkerHandler(async (job: CampaignBatchJob) => {
const { campaignId } = job.data;
const campaign = await db.campaign.findUnique({
where: { id: campaignId },
});
if (!campaign) return;
if (!campaign.contactBookId) return;
// Skip paused campaigns
if (campaign.status === "PAUSED") return;
// Respect scheduledAt if set
if (campaign.scheduledAt && campaign.scheduledAt.getTime() > Date.now())
return;
// First touch moves SCHEDULED -> RUNNING
if (campaign.status === "SCHEDULED") {
await db.campaign.update({
where: { id: campaignId },
data: { status: "RUNNING" },
});
}
const batchSize = campaign.batchSize ?? 500;
const where = {
contactBookId: campaign.contactBookId,
subscribed: true,
} as const;
const pagination: any = {
take: batchSize,
orderBy: { id: "asc" as const },
};
if (campaign.lastCursor) {
pagination.cursor = { id: campaign.lastCursor };
pagination.skip = 1; // do not include the cursor row
}
const contacts = await db.contact.findMany({ where, ...pagination });
if (contacts.length === 0) {
// No more contacts -> mark SENT
await db.campaign.update({
where: { id: campaignId },
data: { status: "SENT" },
});
return;
}
// Fetch domain for region and id
const domain = await db.domain.findUnique({
where: { id: campaign.domainId },
});
if (!domain) return;
// Bulk existence check to avoid duplicates while unique is not enforced
const existing = await db.campaignEmail.findMany({
where: {
campaignId: campaign.id,
contactId: { in: contacts.map((c) => c.id) },
},
select: { contactId: true },
});
const existingSet = new Set(existing.map((e) => e.contactId));
// Process each contact in this batch
for (const contact of contacts) {
if (existingSet.has(contact.id)) continue;
await processContactEmail({
contact,
campaign,
emailConfig: {
from: campaign.from,
subject: campaign.subject,
replyTo: Array.isArray(campaign.replyTo) ? campaign.replyTo : [],
cc: Array.isArray(campaign.cc) ? campaign.cc : [],
bcc: Array.isArray(campaign.bcc) ? campaign.bcc : [],
teamId: campaign.teamId,
campaignId: campaign.id,
previewText: campaign.previewText ?? undefined,
domainId: domain.id,
region: domain.region,
},
});
}
// Advance cursor and timestamp
const newCursor = contacts[contacts.length - 1]?.id;
await db.campaign.update({
where: { id: campaignId },
data: { lastCursor: newCursor, lastSentAt: new Date() },
});
}),
{
connection: getRedis(),
concurrency: CAMPAIGN_EMAIL_CONCURRENCY,
}
{ connection: getRedis(), concurrency: 20 }
);
static async queueContact(data: CampaignEmailJob) {
return await this.campaignQueue.add(
`contact-${data.contact.id}`,
{
...data,
teamId: data.emailConfig.teamId,
},
DEFAULT_QUEUE_OPTIONS
);
}
static async queueBatch({
campaignId,
teamId,
}: {
campaignId: string;
teamId?: number;
}) {
// Defensive check: avoid enqueue if window not elapsed (scheduler already enforces)
try {
const campaign = await db.campaign.findUnique({
where: { id: campaignId },
select: { lastSentAt: true, batchWindowMinutes: true, status: true },
});
if (!campaign) return;
if (campaign.status === "PAUSED" || campaign.status === "SENT") return;
const windowMin = campaign.batchWindowMinutes ?? 0;
if (windowMin > 0 && campaign.lastSentAt) {
const elapsedMs = Date.now() - new Date(campaign.lastSentAt).getTime();
const windowMs = windowMin * 60 * 1000;
if (elapsedMs < windowMs) {
logger.debug(
{ campaignId, remainingMs: windowMs - elapsedMs },
"Defensive skip enqueue; window not elapsed"
);
return;
}
}
} catch (err) {
logger.warn(
{ err, campaignId },
"Failed defensive window check; proceeding to enqueue"
);
}
static async queueBulkContacts(data: CampaignEmailJob[]) {
return await this.campaignQueue.addBulk(
data.map((item) => ({
name: `contact-${item.contact.id}`,
data: {
...item,
teamId: item.emailConfig.teamId,
},
opts: {
...DEFAULT_QUEUE_OPTIONS,
},
}))
await this.batchQueue.add(
`campaign-${campaignId}`,
{ campaignId, teamId },
{ jobId: `campaign-batch:${campaignId}`, ...DEFAULT_QUEUE_OPTIONS }
);
}
}