611 lines
13 KiB
TypeScript
611 lines
13 KiB
TypeScript
import { z } from "zod";
|
|
import { authProcedure } from "../trpc";
|
|
import { prisma } from "../utils/prisma";
|
|
import { TRPCError } from "@trpc/server";
|
|
import pMap from "p-map";
|
|
import { Mailer } from "../lib/Mailer";
|
|
|
|
const createCampaignSchema = z.object({
|
|
title: z.string().min(1, "Campaign title is required"),
|
|
description: z.string().optional(),
|
|
organizationId: z.string(),
|
|
});
|
|
|
|
export const createCampaign = authProcedure
|
|
.input(createCampaignSchema)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const userOrganization = await prisma.userOrganization.findFirst({
|
|
where: {
|
|
userId: ctx.user.id,
|
|
organizationId: input.organizationId,
|
|
},
|
|
});
|
|
|
|
if (!userOrganization) {
|
|
throw new TRPCError({
|
|
code: "UNAUTHORIZED",
|
|
message: "Organization not found",
|
|
});
|
|
}
|
|
|
|
const campaign = await prisma.campaign.create({
|
|
data: {
|
|
title: input.title,
|
|
description: input.description,
|
|
organizationId: input.organizationId,
|
|
status: "DRAFT",
|
|
},
|
|
include: {
|
|
Template: true,
|
|
CampaignLists: {
|
|
include: {
|
|
List: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
return { campaign };
|
|
});
|
|
|
|
const updateCampaignSchema = z.object({
|
|
id: z.string(),
|
|
organizationId: z.string(),
|
|
title: z.string().optional(),
|
|
description: z.string().optional().nullable(),
|
|
subject: z.string().optional().nullable(),
|
|
templateId: z.string().optional().nullable(),
|
|
listIds: z.array(z.string()).optional(),
|
|
scheduledAt: z.date().optional().nullable(),
|
|
content: z.string().optional().nullable(),
|
|
openTracking: z.boolean().optional(),
|
|
});
|
|
|
|
export const updateCampaign = authProcedure
|
|
.input(updateCampaignSchema)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const userOrganization = await prisma.userOrganization.findFirst({
|
|
where: {
|
|
userId: ctx.user.id,
|
|
organizationId: input.organizationId,
|
|
},
|
|
});
|
|
|
|
if (!userOrganization) {
|
|
throw new TRPCError({
|
|
code: "UNAUTHORIZED",
|
|
message: "Organization not found",
|
|
});
|
|
}
|
|
|
|
const campaign = await prisma.campaign.findFirst({
|
|
where: {
|
|
id: input.id,
|
|
organizationId: input.organizationId,
|
|
},
|
|
});
|
|
|
|
if (!campaign) {
|
|
throw new TRPCError({
|
|
code: "NOT_FOUND",
|
|
message: "Campaign not found",
|
|
});
|
|
}
|
|
|
|
if (campaign.status !== "DRAFT") {
|
|
throw new TRPCError({
|
|
code: "BAD_REQUEST",
|
|
message: "Campaign can not be updated!",
|
|
});
|
|
}
|
|
|
|
// If a templateId is provided, ensure it exists
|
|
if (input.templateId) {
|
|
const template = await prisma.template.findFirst({
|
|
where: {
|
|
id: input.templateId,
|
|
organizationId: input.organizationId,
|
|
},
|
|
});
|
|
|
|
if (!template) {
|
|
throw new TRPCError({
|
|
code: "NOT_FOUND",
|
|
message: "Template not found",
|
|
});
|
|
}
|
|
}
|
|
|
|
if (input.listIds?.length) {
|
|
const lists = await prisma.list.findMany({
|
|
where: {
|
|
id: { in: input.listIds },
|
|
organizationId: input.organizationId,
|
|
},
|
|
});
|
|
|
|
if (lists.length !== input.listIds.length) {
|
|
throw new TRPCError({
|
|
code: "NOT_FOUND",
|
|
message: "One or more lists not found",
|
|
});
|
|
}
|
|
}
|
|
|
|
const updatedCampaign = await prisma.campaign.update({
|
|
where: { id: input.id },
|
|
data: {
|
|
title: input.title,
|
|
description: input.description,
|
|
subject: input.subject,
|
|
content: input.content,
|
|
templateId: input.templateId,
|
|
scheduledAt: input.scheduledAt,
|
|
openTracking: input.openTracking,
|
|
CampaignLists: {
|
|
deleteMany: {},
|
|
create: input.listIds?.map((listId) => ({
|
|
listId,
|
|
})),
|
|
},
|
|
},
|
|
include: {
|
|
Template: true,
|
|
CampaignLists: {
|
|
include: {
|
|
List: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
return { campaign: updatedCampaign };
|
|
});
|
|
|
|
export const deleteCampaign = authProcedure
|
|
.input(
|
|
z.object({
|
|
id: z.string(),
|
|
organizationId: z.string(),
|
|
}),
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const userOrganization = await prisma.userOrganization.findFirst({
|
|
where: {
|
|
userId: ctx.user.id,
|
|
organizationId: input.organizationId,
|
|
},
|
|
});
|
|
|
|
if (!userOrganization) {
|
|
throw new TRPCError({
|
|
code: "UNAUTHORIZED",
|
|
message: "Organization not found",
|
|
});
|
|
}
|
|
|
|
const campaign = await prisma.campaign.findFirst({
|
|
where: {
|
|
id: input.id,
|
|
organizationId: input.organizationId,
|
|
},
|
|
});
|
|
|
|
if (!campaign) {
|
|
throw new TRPCError({
|
|
code: "NOT_FOUND",
|
|
message: "Campaign not found",
|
|
});
|
|
}
|
|
|
|
// On Delete: Cascade delete all messages
|
|
await prisma.campaign.delete({
|
|
where: { id: input.id },
|
|
});
|
|
|
|
return { success: true };
|
|
});
|
|
|
|
export const startCampaign = authProcedure
|
|
.input(
|
|
z.object({
|
|
id: z.string(),
|
|
organizationId: z.string(),
|
|
}),
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const userOrganization = await prisma.userOrganization.findFirst({
|
|
where: {
|
|
userId: ctx.user.id,
|
|
organizationId: input.organizationId,
|
|
},
|
|
});
|
|
|
|
if (!userOrganization) {
|
|
throw new TRPCError({
|
|
code: "UNAUTHORIZED",
|
|
message: "Organization not found",
|
|
});
|
|
}
|
|
|
|
const [smtpSettings, emailSettings] = await Promise.all([
|
|
prisma.smtpSettings.findFirst({
|
|
where: {
|
|
organizationId: input.organizationId,
|
|
},
|
|
}),
|
|
prisma.emailDeliverySettings.findFirst({
|
|
where: {
|
|
organizationId: input.organizationId,
|
|
},
|
|
}),
|
|
]);
|
|
|
|
if (!smtpSettings) {
|
|
throw new TRPCError({
|
|
code: "BAD_REQUEST",
|
|
message:
|
|
"You must configure your SMTP settings before running a campaign",
|
|
});
|
|
}
|
|
|
|
if (!emailSettings) {
|
|
throw new TRPCError({
|
|
code: "BAD_REQUEST",
|
|
message:
|
|
"You must configure your email delivery settings before running a campaign",
|
|
});
|
|
}
|
|
|
|
const campaign = await prisma.campaign.findFirst({
|
|
where: {
|
|
id: input.id,
|
|
organizationId: input.organizationId,
|
|
},
|
|
include: {
|
|
Template: true,
|
|
CampaignLists: {
|
|
include: {
|
|
List: {
|
|
include: {
|
|
ListSubscribers: {
|
|
where: {
|
|
unsubscribedAt: null,
|
|
},
|
|
include: {
|
|
Subscriber: {
|
|
select: {
|
|
id: true,
|
|
email: true,
|
|
name: true,
|
|
Metadata: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!campaign) {
|
|
throw new TRPCError({
|
|
code: "NOT_FOUND",
|
|
message: "Campaign not found",
|
|
});
|
|
}
|
|
|
|
// Check campaign status
|
|
if (campaign.status !== "DRAFT") {
|
|
throw new TRPCError({
|
|
code: "BAD_REQUEST",
|
|
message: "Campaign can only be started from DRAFT status",
|
|
});
|
|
}
|
|
|
|
if (!campaign.subject) {
|
|
throw new TRPCError({
|
|
code: "BAD_REQUEST",
|
|
message: "Email Subject is required",
|
|
});
|
|
}
|
|
|
|
// Check campaign has lists
|
|
if (campaign.CampaignLists.length === 0) {
|
|
throw new TRPCError({
|
|
code: "BAD_REQUEST",
|
|
message: "Campaign must have at least one list",
|
|
});
|
|
}
|
|
|
|
if (!campaign.content) {
|
|
throw new TRPCError({
|
|
code: "BAD_REQUEST",
|
|
message:
|
|
"Can not send an empty campaign. Write some content in the editor to start sending.",
|
|
});
|
|
}
|
|
|
|
type Subscriber =
|
|
(typeof campaign)["CampaignLists"][0]["List"]["ListSubscribers"][0]["Subscriber"] & {
|
|
Metadata: { key: string; value: string }[];
|
|
};
|
|
|
|
const subscribers = new Map<string, Subscriber>();
|
|
await pMap(campaign.CampaignLists, (campaignList) => {
|
|
return pMap(campaignList.List.ListSubscribers, (listSubscriber) => {
|
|
subscribers.set(
|
|
listSubscriber.Subscriber.id,
|
|
listSubscriber.Subscriber,
|
|
);
|
|
});
|
|
});
|
|
|
|
if (subscribers.size === 0) {
|
|
throw new TRPCError({
|
|
code: "BAD_REQUEST",
|
|
message: "Campaign must have at least one recipient",
|
|
});
|
|
}
|
|
|
|
const organization = await prisma.organization.findUnique({
|
|
where: { id: input.organizationId },
|
|
select: { name: true },
|
|
});
|
|
|
|
if (!organization) {
|
|
throw new TRPCError({
|
|
code: "INTERNAL_SERVER_ERROR",
|
|
message: "Organization details could not be retrieved.",
|
|
});
|
|
}
|
|
|
|
const generalSettings = await prisma.generalSettings.findFirst({
|
|
where: {
|
|
organizationId: input.organizationId,
|
|
},
|
|
});
|
|
|
|
if (!generalSettings?.baseURL) {
|
|
throw new TRPCError({
|
|
code: "BAD_REQUEST",
|
|
message:
|
|
"Base URL must be configured in settings before running a campaign",
|
|
});
|
|
}
|
|
|
|
const status =
|
|
campaign.scheduledAt && campaign.scheduledAt > new Date()
|
|
? "SCHEDULED"
|
|
: "CREATING";
|
|
|
|
const updatedCampaign = await prisma.campaign.update({
|
|
where: { id: campaign.id },
|
|
data: {
|
|
status,
|
|
},
|
|
});
|
|
|
|
return { campaign: updatedCampaign };
|
|
});
|
|
|
|
export const cancelCampaign = authProcedure
|
|
.input(
|
|
z.object({
|
|
id: z.string(),
|
|
organizationId: z.string(),
|
|
}),
|
|
)
|
|
.mutation(async ({ input }) => {
|
|
const campaign = await prisma.campaign.findFirst({
|
|
where: {
|
|
id: input.id,
|
|
organizationId: input.organizationId,
|
|
},
|
|
});
|
|
|
|
if (!campaign) {
|
|
throw new TRPCError({
|
|
code: "NOT_FOUND",
|
|
message: "Campaign not found",
|
|
});
|
|
}
|
|
|
|
if (!["CREATING", "SENDING", "SCHEDULED"].includes(campaign.status)) {
|
|
throw new TRPCError({
|
|
code: "BAD_REQUEST",
|
|
message: "Campaign cannot be 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 };
|
|
});
|
|
|
|
export const sendTestEmail = authProcedure
|
|
.input(
|
|
z.object({
|
|
campaignId: z.string(),
|
|
organizationId: z.string(),
|
|
email: z.string().email(),
|
|
}),
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const userOrganization = await prisma.userOrganization.findFirst({
|
|
where: {
|
|
userId: ctx.user.id,
|
|
organizationId: input.organizationId,
|
|
},
|
|
});
|
|
|
|
if (!userOrganization) {
|
|
throw new TRPCError({
|
|
code: "UNAUTHORIZED",
|
|
message: "Organization not found",
|
|
});
|
|
}
|
|
|
|
const settings = await prisma.smtpSettings.findFirst({
|
|
where: {
|
|
organizationId: input.organizationId,
|
|
},
|
|
});
|
|
|
|
if (!settings) {
|
|
throw new TRPCError({
|
|
code: "BAD_REQUEST",
|
|
message:
|
|
"You must configure your SMTP settings before sending test emails",
|
|
});
|
|
}
|
|
|
|
const campaign = await prisma.campaign.findFirst({
|
|
where: {
|
|
id: input.campaignId,
|
|
organizationId: input.organizationId,
|
|
},
|
|
include: {
|
|
Template: true,
|
|
},
|
|
});
|
|
|
|
if (!campaign) {
|
|
throw new TRPCError({
|
|
code: "NOT_FOUND",
|
|
message: "Campaign not found",
|
|
});
|
|
}
|
|
|
|
if (!campaign.content) {
|
|
throw new TRPCError({
|
|
code: "BAD_REQUEST",
|
|
message: "Campaign must have content",
|
|
});
|
|
}
|
|
|
|
if (!campaign.subject) {
|
|
throw new TRPCError({
|
|
code: "BAD_REQUEST",
|
|
message: "Email Subject is required",
|
|
});
|
|
}
|
|
|
|
const content = campaign.Template
|
|
? campaign.Template.content.replace(/{{content}}/g, campaign.content)
|
|
: campaign.content;
|
|
|
|
const mailer = new Mailer(settings);
|
|
|
|
const result = await mailer.sendEmail({
|
|
to: input.email,
|
|
subject: `[Test] ${campaign.subject}`,
|
|
html: content,
|
|
from: `${settings.fromName} <${settings.fromEmail}>`,
|
|
});
|
|
|
|
if (!result.success) {
|
|
throw new TRPCError({
|
|
code: "INTERNAL_SERVER_ERROR",
|
|
message: "Failed to send test email",
|
|
});
|
|
}
|
|
|
|
return { success: true };
|
|
});
|
|
|
|
const duplicateCampaignSchema = z.object({
|
|
id: z.string(),
|
|
organizationId: z.string(),
|
|
});
|
|
|
|
export const duplicateCampaign = authProcedure
|
|
.input(duplicateCampaignSchema)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const userOrganization = await prisma.userOrganization.findFirst({
|
|
where: {
|
|
userId: ctx.user.id,
|
|
organizationId: input.organizationId,
|
|
},
|
|
});
|
|
|
|
if (!userOrganization) {
|
|
throw new TRPCError({
|
|
code: "UNAUTHORIZED",
|
|
message: "Organization not found",
|
|
});
|
|
}
|
|
|
|
const originalCampaign = await prisma.campaign.findFirst({
|
|
where: {
|
|
id: input.id,
|
|
organizationId: input.organizationId,
|
|
},
|
|
include: {
|
|
Template: true,
|
|
CampaignLists: {
|
|
include: {
|
|
List: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!originalCampaign) {
|
|
throw new TRPCError({
|
|
code: "NOT_FOUND",
|
|
message: "Campaign not found",
|
|
});
|
|
}
|
|
|
|
const newCampaign = await prisma.campaign.create({
|
|
data: {
|
|
title: `Copy of ${originalCampaign.title}`,
|
|
description: originalCampaign.description,
|
|
subject: originalCampaign.subject,
|
|
content: originalCampaign.content,
|
|
templateId: originalCampaign.templateId,
|
|
organizationId: originalCampaign.organizationId,
|
|
status: "DRAFT",
|
|
openTracking: originalCampaign.openTracking,
|
|
CampaignLists: {
|
|
create: originalCampaign.CampaignLists.map((cl) => ({
|
|
listId: cl.listId,
|
|
})),
|
|
},
|
|
},
|
|
include: {
|
|
Template: true,
|
|
CampaignLists: {
|
|
include: {
|
|
List: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
return { campaign: newCampaign };
|
|
});
|