Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 3 additions & 6 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
### ✨ Features

- Ability to manually resend messages.
- Filter messages by status.
- Messages are now ordered by last updated time.
- Maintenance only deletes the message content now, not the message entries.
- New features and improvements.
- Message status independence - retry individual messages regardless of campaign status

### 🐛 Bug Fixes

- Various bug fixes and optimizations.
- Fixed campaign creation targeting wrong subscribers (now filters by selected lists only)
- Fixed campaign cancellation

### 📚 Docs

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "MessageStatus" ADD VALUE 'CANCELLED';
1 change: 1 addition & 0 deletions apps/backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ enum MessageStatus {
CLICKED
FAILED
RETRYING
CANCELLED
}

model Message {
Expand Down
36 changes: 25 additions & 11 deletions apps/backend/src/campaign/mutation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ export const deleteCampaign = authProcedure
})
}

// On Delete: Cascade delete all messages
await prisma.campaign.delete({
where: { id: input.id },
})
Expand Down Expand Up @@ -387,7 +388,7 @@ export const startCampaign = authProcedure
return { campaign: updatedCampaign }
})

export const cancel = authProcedure
export const cancelCampaign = authProcedure
.input(
z.object({
id: z.string(),
Expand All @@ -409,21 +410,34 @@ export const cancel = authProcedure
})
}

if (campaign.status !== "SENDING") {
if (!["CREATING", "SENDING", "SCHEDULED"].includes(campaign.status)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Campaign is not in sending state",
message: "Campaign cannot be cancelled",
})
}

await prisma.campaign.update({
where: {
id: input.id,
},
data: {
status: "CANCELLED",
},
})
await prisma.$transaction([
prisma.campaign.update({
where: {
id: input.id,
},
data: {
status: "CANCELLED",
},
}),
prisma.message.updateMany({
where: {
campaignId: input.id,
status: {
in: ["QUEUED", "PENDING", "RETRYING"],
},
},
data: {
status: "CANCELLED",
},
}),
])

return { success: true }
})
Expand Down
4 changes: 2 additions & 2 deletions apps/backend/src/campaign/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
updateCampaign,
deleteCampaign,
startCampaign,
cancel,
cancelCampaign,
sendTestEmail,
duplicateCampaign,
} from "./mutation"
Expand All @@ -17,7 +17,7 @@ export const campaignRouter = router({
get: getCampaign,
list: listCampaigns,
start: startCampaign,
cancel,
cancel: cancelCampaign,
sendTestEmail,
duplicate: duplicateCampaign,
})
19 changes: 17 additions & 2 deletions apps/backend/src/cron/processQueuedCampaigns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,19 @@ import { cronJob } from "./cron.utils"
const BATCH_SIZE = 100

async function getSubscribersForCampaign(
campaignId: string
campaignId: string,
selectedListIds: string[]
): Promise<Map<string, Subscriber & { Metadata: SubscriberMetadata[] }>> {
if (selectedListIds.length === 0) {
return new Map()
}

const subscribers = await prisma.subscriber.findMany({
where: {
Messages: { none: { campaignId } },
ListSubscribers: {
some: {
listId: { in: selectedListIds },
unsubscribedAt: null,
},
},
Expand Down Expand Up @@ -71,6 +77,9 @@ export const processQueuedCampaigns = cronJob(
status: "CREATING",
},
include: {
CampaignLists: {
select: { listId: true },
},
Organization: {
include: {
GeneralSettings: true,
Expand Down Expand Up @@ -113,7 +122,12 @@ export const processQueuedCampaigns = cronJob(

const generalSettings = campaign.Organization.GeneralSettings

const allSubscribersMap = await getSubscribersForCampaign(campaign.id)
const selectedListIds = campaign.CampaignLists.map((cl) => cl.listId)

const allSubscribersMap = await getSubscribersForCampaign(
campaign.id,
selectedListIds
)
if (allSubscribersMap.size === 0) {
oneTimeLogger(
"noSubscribers",
Expand Down Expand Up @@ -234,6 +248,7 @@ export const processQueuedCampaigns = cronJob(
Messages: { none: { campaignId: campaign.id } },
ListSubscribers: {
some: {
listId: { in: selectedListIds },
unsubscribedAt: null,
},
},
Expand Down
31 changes: 15 additions & 16 deletions apps/backend/src/cron/sendMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,26 +50,22 @@ export const sendMessagesCron = cronJob("sendMessages", async () => {
continue
}

// Message status is now independent of campaign status.
// This allows retrying individual messages even for completed campaigns.
// We only filter by QUEUED and RETRYING message statuses.
const messages = await prisma.message.findMany({
where: {
AND: [
Campaign: {
organizationId: organization.id,
},
OR: [
{ status: "QUEUED" },
{
Campaign: {
status: "SENDING",
organizationId: organization.id,
status: "RETRYING",
lastTriedAt: {
lte: subSeconds(new Date(), emailSettings.retryDelay),
},
},
{
OR: [
{ status: "QUEUED" },
{
status: "RETRYING",
lastTriedAt: {
lte: subSeconds(new Date(), emailSettings.retryDelay),
},
},
],
},
],
},
include: {
Expand All @@ -90,6 +86,9 @@ export const sendMessagesCron = cronJob("sendMessages", async () => {
const noMoreRetryingMessages = await prisma.message.count({
where: {
status: "RETRYING",
Campaign: {
organizationId: organization.id,
},
},
})

Expand All @@ -101,7 +100,7 @@ export const sendMessagesCron = cronJob("sendMessages", async () => {
Messages: {
every: {
status: {
in: ["SENT", "FAILED", "OPENED", "CLICKED"],
in: ["SENT", "FAILED", "OPENED", "CLICKED", "CANCELLED"],
},
},
},
Expand Down
24 changes: 22 additions & 2 deletions apps/backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,33 @@ export type * from "./types"

import { app } from "./app"
import { initializeCronJobs } from "./cron/cron"
import { prisma } from "./utils/prisma"

const cronController = initializeCronJobs()

const PORT = process.env.PORT || 5000

app.listen(PORT, () => {
console.log(`Server is running on port ${PORT}`)
prisma.$connect().then(async () => {
console.log("Connected to database")

// For backwards compatibility, set all messages that have campaign status === "CANCELLED" to "CANCELLED"
await prisma.message.updateMany({
where: {
Campaign: {
status: "CANCELLED",
},
status: {
in: ["QUEUED", "PENDING", "RETRYING"],
},
},
data: {
status: "CANCELLED",
},
})

app.listen(PORT, () => {
console.log(`Server is running on port ${PORT}`)
})
})

// Handle graceful shutdown
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,9 @@ export const CampaignActions = () => {
</AlertDialog>
</>
)
case "CREATING":
case "SENDING":
case "SCHEDULED":
return (
<Button
variant="destructive"
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"version": "0.9.0",
"version": "0.9.1",
"name": "letterspace",
"private": true,
"scripts": {
Expand Down