Skip to content

Commit e631f16

Browse files
authored
feat: batch campaigns (#227)
1 parent 159b15e commit e631f16

File tree

22 files changed

+13769
-6509
lines changed

22 files changed

+13769
-6509
lines changed

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
- `pnpm format`: Prettier over ts/tsx/md.
2020
- `pnpm dx` / `pnpm dx:up` / `pnpm dx:down`: Spin up/down local infra via Docker Compose, then run migrations.
2121
- Database (apps/web filter): `pnpm db:generate` | `db:migrate-dev` | `db:push` | `db:studio`.
22+
- Never run migrations unless users explicitly asked
2223

2324
## Coding Style & Naming Conventions
2425

apps/web/package.json

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,11 @@
1414
"db:migrate-dev": "prisma migrate dev",
1515
"db:migrate-deploy": "prisma migrate deploy",
1616
"db:studio": "prisma studio",
17-
"db:migrate-reset": "prisma migrate reset"
17+
"db:migrate-reset": "prisma migrate reset",
18+
"memory:monitor": "node --expose-gc scripts/memory-monitor.js",
19+
"memory:profile": "node --expose-gc scripts/memory-profiler.js",
20+
"memory:test": "node --expose-gc -e \"const MemoryMonitor = require('./scripts/memory-monitor'); const monitor = new MemoryMonitor(); monitor.start(1000); setTimeout(() => monitor.stop(), 30000)\"",
21+
"memory:baseline": "node --expose-gc scripts/baseline-test.js"
1822
},
1923
"dependencies": {
2024
"@auth/prisma-adapter": "^2.9.0",
@@ -100,4 +104,4 @@
100104
"initVersion": "7.30.0"
101105
},
102106
"packageManager": "pnpm@8.9.2"
103-
}
107+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
-- AlterEnum
2+
-- This migration adds more than one value to an enum.
3+
-- With PostgreSQL versions 11 and earlier, this is not possible
4+
-- in a single migration. This can be worked around by creating
5+
-- multiple migrations, each migration adding only one value to
6+
-- the enum.
7+
8+
9+
ALTER TYPE "CampaignStatus" ADD VALUE 'RUNNING';
10+
ALTER TYPE "CampaignStatus" ADD VALUE 'PAUSED';
11+
12+
-- AlterTable
13+
ALTER TABLE "Campaign" ADD COLUMN "batchSize" INTEGER NOT NULL DEFAULT 500,
14+
ADD COLUMN "batchWindowMinutes" INTEGER NOT NULL DEFAULT 0,
15+
ADD COLUMN "lastCursor" TEXT,
16+
ADD COLUMN "lastSentAt" TIMESTAMP(3),
17+
ADD COLUMN "scheduledAt" TIMESTAMP(3);
18+
19+
-- CreateTable
20+
CREATE TABLE "CampaignEmail" (
21+
"campaignId" TEXT NOT NULL,
22+
"contactId" TEXT NOT NULL,
23+
"emailId" TEXT NOT NULL,
24+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
25+
26+
CONSTRAINT "CampaignEmail_pkey" PRIMARY KEY ("campaignId","contactId")
27+
);
28+
29+
-- CreateIndex
30+
CREATE INDEX "Campaign_status_scheduledAt_idx" ON "Campaign"("status", "scheduledAt");
31+
32+
-- CreateIndex
33+
CREATE INDEX "Contact_contactBookId_id_idx" ON "Contact"("contactBookId", "id");
34+
35+
-- CreateIndex
36+
CREATE INDEX "Email_campaignId_contactId_idx" ON "Email"("campaignId", "contactId");

apps/web/prisma/schema.prisma

Lines changed: 44 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -263,9 +263,19 @@ model Email {
263263
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
264264
emailEvents EmailEvent[]
265265
266+
@@index([campaignId, contactId])
266267
@@index([createdAt(sort: Desc)])
267268
}
268269

270+
model CampaignEmail {
271+
campaignId String
272+
contactId String
273+
emailId String
274+
createdAt DateTime @default(now())
275+
276+
@@id([campaignId, contactId])
277+
}
278+
269279
model EmailEvent {
270280
id String @id @default(cuid())
271281
emailId String
@@ -320,44 +330,53 @@ model Contact {
320330
contactBook ContactBook @relation(fields: [contactBookId], references: [id], onDelete: Cascade)
321331
322332
@@unique([contactBookId, email])
333+
@@index([contactBookId, id])
323334
}
324335

325336
enum CampaignStatus {
326337
DRAFT
327338
SCHEDULED
339+
RUNNING
340+
PAUSED
328341
SENT
329342
}
330343

331344
model Campaign {
332-
id String @id @default(cuid())
333-
name String
334-
teamId Int
335-
from String
336-
cc String[]
337-
bcc String[]
338-
replyTo String[]
339-
domainId Int
340-
subject String
341-
previewText String?
342-
html String?
343-
content String?
344-
contactBookId String?
345-
total Int @default(0)
346-
sent Int @default(0)
347-
delivered Int @default(0)
348-
opened Int @default(0)
349-
clicked Int @default(0)
350-
unsubscribed Int @default(0)
351-
bounced Int @default(0)
352-
hardBounced Int @default(0)
353-
complained Int @default(0)
354-
status CampaignStatus @default(DRAFT)
355-
createdAt DateTime @default(now())
356-
updatedAt DateTime @updatedAt
345+
id String @id @default(cuid())
346+
name String
347+
teamId Int
348+
from String
349+
cc String[]
350+
bcc String[]
351+
replyTo String[]
352+
domainId Int
353+
subject String
354+
previewText String?
355+
html String?
356+
content String?
357+
contactBookId String?
358+
scheduledAt DateTime?
359+
total Int @default(0)
360+
sent Int @default(0)
361+
delivered Int @default(0)
362+
opened Int @default(0)
363+
clicked Int @default(0)
364+
unsubscribed Int @default(0)
365+
bounced Int @default(0)
366+
hardBounced Int @default(0)
367+
complained Int @default(0)
368+
status CampaignStatus @default(DRAFT)
369+
batchSize Int @default(500)
370+
batchWindowMinutes Int @default(0)
371+
lastCursor String?
372+
lastSentAt DateTime?
373+
createdAt DateTime @default(now())
374+
updatedAt DateTime @updatedAt
357375
358376
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
359377
360378
@@index([createdAt(sort: Desc)])
379+
@@index([status, scheduledAt])
361380
}
362381

363382
model Template {

apps/web/src/app/(dashboard)/campaigns/[campaignId]/edit/page.tsx

Lines changed: 23 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ import {
4141
AccordionItem,
4242
AccordionTrigger,
4343
} from "@usesend/ui/src/accordion";
44+
import ScheduleCampaign from "../../schedule-campaign";
45+
import { useRouter } from "next/navigation";
4446

4547
const sendSchema = z.object({
4648
confirmation: z.string(),
@@ -63,7 +65,7 @@ export default function EditCampaignPage({
6365
{ campaignId },
6466
{
6567
enabled: !!campaignId,
66-
},
68+
}
6769
);
6870

6971
if (isLoading) {
@@ -94,38 +96,33 @@ function CampaignEditor({
9496
}: {
9597
campaign: Campaign & { imageUploadSupported: boolean };
9698
}) {
99+
const router = useRouter();
97100
const contactBooksQuery = api.contacts.getContactBooks.useQuery({});
98101
const utils = api.useUtils();
99102

100103
const [json, setJson] = useState<Record<string, any> | undefined>(
101-
campaign.content ? JSON.parse(campaign.content) : undefined,
104+
campaign.content ? JSON.parse(campaign.content) : undefined
102105
);
103106
const [isSaving, setIsSaving] = useState(false);
104107
const [name, setName] = useState(campaign.name);
105108
const [subject, setSubject] = useState(campaign.subject);
106109
const [from, setFrom] = useState(campaign.from);
107110
const [contactBookId, setContactBookId] = useState(campaign.contactBookId);
108111
const [replyTo, setReplyTo] = useState<string | undefined>(
109-
campaign.replyTo[0],
112+
campaign.replyTo[0]
110113
);
111114
const [previewText, setPreviewText] = useState<string | null>(
112-
campaign.previewText,
115+
campaign.previewText
113116
);
114-
const [openSendDialog, setOpenSendDialog] = useState(false);
115117

116118
const updateCampaignMutation = api.campaign.updateCampaign.useMutation({
117119
onSuccess: () => {
118120
utils.campaign.getCampaign.invalidate();
119121
setIsSaving(false);
120122
},
121123
});
122-
const sendCampaignMutation = api.campaign.sendCampaign.useMutation();
123124
const getUploadUrl = api.campaign.generateImagePresignedUrl.useMutation();
124125

125-
const sendForm = useForm<z.infer<typeof sendSchema>>({
126-
resolver: zodResolver(sendSchema),
127-
});
128-
129126
function updateEditorContent() {
130127
updateCampaignMutation.mutate({
131128
campaignId: campaign.id,
@@ -135,39 +132,13 @@ function CampaignEditor({
135132

136133
const deboucedUpdateCampaign = useDebouncedCallback(
137134
updateEditorContent,
138-
1000,
135+
1000
139136
);
140137

141-
async function onSendCampaign(values: z.infer<typeof sendSchema>) {
142-
if (
143-
values.confirmation?.toLocaleLowerCase() !== "Send".toLocaleLowerCase()
144-
) {
145-
sendForm.setError("confirmation", {
146-
message: "Please type 'Send' to confirm",
147-
});
148-
return;
149-
}
150-
151-
sendCampaignMutation.mutate(
152-
{
153-
campaignId: campaign.id,
154-
},
155-
{
156-
onSuccess: () => {
157-
setOpenSendDialog(false);
158-
toast.success(`Campaign sent successfully`);
159-
},
160-
onError: (error) => {
161-
toast.error(`Failed to send campaign: ${error.message}`);
162-
},
163-
},
164-
);
165-
}
166-
167138
const handleFileChange = async (file: File) => {
168139
if (file.size > IMAGE_SIZE_LIMIT) {
169140
throw new Error(
170-
`File should be less than ${IMAGE_SIZE_LIMIT / 1024 / 1024}MB`,
141+
`File should be less than ${IMAGE_SIZE_LIMIT / 1024 / 1024}MB`
171142
);
172143
}
173144

@@ -191,10 +162,8 @@ function CampaignEditor({
191162
return imageUrl;
192163
};
193164

194-
const confirmation = sendForm.watch("confirmation");
195-
196165
const contactBook = contactBooksQuery.data?.find(
197-
(book) => book.id === contactBookId,
166+
(book) => book.id === contactBookId
198167
);
199168

200169
return (
@@ -220,7 +189,7 @@ function CampaignEditor({
220189
toast.error(`${e.message}. Reverting changes.`);
221190
setName(campaign.name);
222191
},
223-
},
192+
}
224193
);
225194
}}
226195
/>
@@ -235,56 +204,13 @@ function CampaignEditor({
235204
? "just now"
236205
: `${formatDistanceToNow(campaign.updatedAt)} ago`}
237206
</div>
238-
<Dialog open={openSendDialog} onOpenChange={setOpenSendDialog}>
239-
<DialogTrigger asChild>
240-
<Button variant="default">Send Campaign</Button>
241-
</DialogTrigger>
242-
<DialogContent>
243-
<DialogHeader>
244-
<DialogTitle>Send Campaign</DialogTitle>
245-
<DialogDescription>
246-
Are you sure you want to send this campaign? This action
247-
cannot be undone.
248-
</DialogDescription>
249-
</DialogHeader>
250-
<div className="py-2">
251-
<Form {...sendForm}>
252-
<form
253-
onSubmit={sendForm.handleSubmit(onSendCampaign)}
254-
className="space-y-4"
255-
>
256-
<FormField
257-
control={sendForm.control}
258-
name="confirmation"
259-
render={({ field }) => (
260-
<FormItem>
261-
<FormLabel>Type 'Send' to confirm</FormLabel>
262-
<FormControl>
263-
<Input {...field} />
264-
</FormControl>
265-
<FormMessage />
266-
</FormItem>
267-
)}
268-
/>
269-
<div className="flex justify-end">
270-
<Button
271-
type="submit"
272-
disabled={
273-
sendCampaignMutation.isPending ||
274-
confirmation?.toLocaleLowerCase() !==
275-
"Send".toLocaleLowerCase()
276-
}
277-
>
278-
{sendCampaignMutation.isPending
279-
? "Sending..."
280-
: "Send"}
281-
</Button>
282-
</div>
283-
</form>
284-
</Form>
285-
</div>
286-
</DialogContent>
287-
</Dialog>
207+
208+
<ScheduleCampaign
209+
campaign={campaign}
210+
onScheduled={() => {
211+
router.push(`/campaigns/${campaign.id}`);
212+
}}
213+
/>
288214
</div>
289215
</div>
290216

@@ -315,7 +241,7 @@ function CampaignEditor({
315241
toast.error(`${e.message}. Reverting changes.`);
316242
setSubject(campaign.subject);
317243
},
318-
},
244+
}
319245
);
320246
}}
321247
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({
350276
toast.error(`${e.message}. Reverting changes.`);
351277
setFrom(campaign.from);
352278
},
353-
},
279+
}
354280
);
355281
}}
356282
/>
@@ -381,7 +307,7 @@ function CampaignEditor({
381307
toast.error(`${e.message}. Reverting changes.`);
382308
setReplyTo(campaign.replyTo[0]);
383309
},
384-
},
310+
}
385311
);
386312
}}
387313
/>
@@ -414,7 +340,7 @@ function CampaignEditor({
414340
toast.error(`${e.message}. Reverting changes.`);
415341
setPreviewText(campaign.previewText ?? "");
416342
},
417-
},
343+
}
418344
);
419345
}}
420346
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({
440366
onError: () => {
441367
setContactBookId(campaign.contactBookId);
442368
},
443-
},
369+
}
444370
);
445371
setContactBookId(val);
446372
}}

0 commit comments

Comments
 (0)