Files
plunk/packages/api/src/controllers/v1/Campaigns.ts
T

399 lines
9.9 KiB
TypeScript

import { Controller, Delete, Get, Middleware, Post, Put } from "@overnightjs/core";
import { CampaignSchemas, UtilitySchemas } from "@plunk/shared";
import dayjs from "dayjs";
import type { Request, Response } from "express";
import { prisma } from "../../database/prisma";
import { HttpException, NotAllowed, NotFound } from "../../exceptions";
import { type IJwt, type ISecret, isAuthenticated, isValidSecretKey } from "../../middleware/auth";
import { CampaignService } from "../../services/CampaignService";
import { ContactService } from "../../services/ContactService";
import { EmailService } from "../../services/EmailService";
import { MembershipService } from "../../services/MembershipService";
import { ProjectService } from "../../services/ProjectService";
import { Keys } from "../../services/keys";
import { redis } from "../../services/redis";
@Controller("campaigns")
export class Campaigns {
@Get(":id")
@Middleware([isAuthenticated])
public async getCampaignById(req: Request, res: Response) {
const { id } = UtilitySchemas.id.parse(req.params);
const { userId } = res.locals.auth as IJwt;
const campaign = await CampaignService.id(id);
if (!campaign) {
throw new NotFound("campaign");
}
const isMember = await MembershipService.isMember(campaign.projectId, userId);
if (!isMember) {
throw new NotFound("campaign");
}
return res.status(200).json(campaign);
}
@Post("send")
@Middleware([isValidSecretKey])
public async sendCampaign(req: Request, res: Response) {
const { sk } = res.locals.auth as ISecret;
const project = await ProjectService.secret(sk);
if (!project) {
throw new NotFound("project");
}
const { id, live, delay: userDelay } = CampaignSchemas.send.parse(req.body);
const campaign = await CampaignService.id(id);
if (!campaign || campaign.projectId !== project.id) {
throw new NotFound("campaign");
}
if (live) {
if (campaign.recipients.length === 0) {
throw new HttpException(400, "No recipients found");
}
await prisma.campaign.update({
where: { id: campaign.id },
data: { status: "DELIVERED", delivered: new Date() },
});
await prisma.event.createMany({
data: [
{
projectId: project.id,
name: `${campaign.subject
.toLowerCase()
.replace(/[.,/#!$%^&*;:{}=\-_`~()]/g, "")
.replace(/ /g, "-")}-campaign-delivered`,
campaignId: campaign.id,
},
{
projectId: project.id,
name: `${campaign.subject
.toLowerCase()
.replace(/[.,/#!$%^&*;:{}=\-_`~()]/g, "")
.replace(/ /g, "-")}-campaign-opened`,
campaignId: campaign.id,
},
],
});
let delay = userDelay ?? 0;
const tasks = campaign.recipients.map((r, index) => {
if (index % 80 === 0) {
delay += 1;
}
return {
campaignId: campaign.id,
contactId: r.id,
runBy: dayjs().add(delay, "minutes").toDate(),
};
});
await prisma.task.createMany({ data: tasks });
} else {
const members = await ProjectService.memberships(project.id);
await EmailService.send({
from: {
name: project.from ?? project.name,
email: project.verified && project.email ? project.email : "no-reply@useplunk.dev",
},
to: members.map((m) => m.email),
content: {
subject: `[Plunk Campaign Test] ${campaign.subject}`,
html: EmailService.compile({
content: campaign.body,
footer: {
unsubscribe: false,
},
contact: {
id: "",
},
project: {
name: project.name,
},
}),
},
});
}
await redis.del(Keys.Campaign.id(campaign.id));
await redis.del(Keys.Project.campaigns(project.id));
return res.status(200).json({});
}
@Post("duplicate")
@Middleware([isValidSecretKey])
public async duplicateCampaign(req: Request, res: Response) {
const { sk } = res.locals.auth as ISecret;
const project = await ProjectService.secret(sk);
if (!project) {
throw new NotFound("project");
}
const { id } = UtilitySchemas.id.parse(req.body);
const campaign = await CampaignService.id(id);
if (!campaign) {
throw new NotFound("campaign");
}
const duplicatedCampaign = await prisma.campaign.create({
data: {
projectId: project.id,
subject: campaign.subject,
body: campaign.body,
style: campaign.style,
email: campaign.email,
from: campaign.from,
},
});
await redis.del(Keys.Campaign.id(campaign.id));
await redis.del(Keys.Project.campaigns(project.id));
return res.status(200).json(duplicatedCampaign);
}
@Post()
@Middleware([isValidSecretKey])
public async createCampaign(req: Request, res: Response) {
const { sk } = res.locals.auth as ISecret;
const project = await ProjectService.secret(sk);
if (!project) {
throw new NotFound("project");
}
let { subject, body, recipients, style, email, from } = CampaignSchemas.create.parse(req.body);
if (email && !project.verified) {
throw new NotAllowed("You need to attach a domain to your project to customize the sender address");
}
if (email && email.split("@")[1] !== project.email?.split("@")[1]) {
throw new NotAllowed("The sender address must be the same domain as the project's email address");
}
if (recipients.length === 1 && recipients[0] === "all") {
const projectContacts = await prisma.contact.findMany({
where: { projectId: project.id, subscribed: true },
select: { id: true },
});
recipients = projectContacts.map((c) => c.id);
}
const campaign = await prisma.campaign.create({
data: {
projectId: project.id,
subject,
body,
style,
from: from === "" ? null : from,
email: email === "" ? null : email,
},
});
const chunkSize = 500;
for (let i = 0; i < recipients.length; i += chunkSize) {
const chunk = recipients.slice(i, i + chunkSize);
const recipientIds = await Promise.all(
chunk.map(async (recipient) => {
if (recipient.includes("@")) {
let contact = await ContactService.email(recipient, project.id);
if (!contact) {
contact = await prisma.contact.create({
data: {
email: recipient,
projectId: project.id,
subscribed: true,
},
});
}
return contact.id;
}
return recipient;
}),
);
await prisma.campaign.update({
where: { id: campaign.id },
data: {
recipients: {
connect: recipientIds.map((id) => ({ id })),
},
},
});
}
await redis.del(Keys.Campaign.id(campaign.id));
await redis.del(Keys.Project.campaigns(project.id));
return res.status(200).json(campaign);
}
@Put()
@Middleware([isValidSecretKey])
public async updateCampaign(req: Request, res: Response) {
const { sk } = res.locals.auth as ISecret;
const project = await ProjectService.secret(sk);
if (!project) {
throw new NotFound("project");
}
let { id, subject, body, recipients, style, email, from } = CampaignSchemas.update.parse(req.body);
if (email && !project.verified) {
throw new NotAllowed("You need to attach a domain to your project to customize the sender address");
}
if (email && email.split("@")[1] !== project.email?.split("@")[1]) {
throw new NotAllowed("The sender address must be the same domain as the project's email address");
}
if (recipients.length === 1 && recipients[0] === "all") {
const projectContacts = await prisma.contact.findMany({
where: { projectId: project.id, subscribed: true },
select: { id: true },
});
recipients = projectContacts.map((c) => c.id);
}
let campaign = await CampaignService.id(id);
if (!campaign || campaign.projectId !== project.id) {
throw new NotFound("campaign");
}
campaign = await prisma.campaign.update({
where: { id },
data: {
subject,
body,
style,
from: from === "" ? null : from,
email: email === "" ? null : email,
},
include: {
recipients: { select: { id: true } },
emails: {
select: {
id: true,
status: true,
contact: { select: { id: true, email: true } },
},
},
},
});
await prisma.campaign.update({
where: { id },
data: {
recipients: {
set: [],
},
},
});
const chunkSize = 500;
for (let i = 0; i < campaign.recipients.length; i += chunkSize) {
const chunk = campaign.recipients.slice(i, i + chunkSize);
await prisma.$executeRaw`DELETE FROM "_CampaignToContact" WHERE "A" = ${campaign.id} AND "B" = ANY(${chunk.map((c) => c.id)})`;
}
for (let i = 0; i < recipients.length; i += chunkSize) {
const chunk = recipients.slice(i, i + chunkSize);
const recipientIds = await Promise.all(
chunk.map(async (recipient) => {
if (recipient.includes("@")) {
let contact = await ContactService.email(recipient, project.id);
if (!contact) {
contact = await prisma.contact.create({
data: {
email: recipient,
projectId: project.id,
subscribed: true,
},
});
}
return contact.id;
}
return recipient;
}),
);
await prisma.campaign.update({
where: { id: campaign.id },
data: {
recipients: {
connect: recipientIds.map((id) => ({ id })),
},
},
});
}
await redis.del(Keys.Campaign.id(campaign.id));
await redis.del(Keys.Project.campaigns(project.id));
return res.status(200).json(campaign);
}
@Delete()
@Middleware([isValidSecretKey])
public async deleteCampaign(req: Request, res: Response) {
const { sk } = res.locals.auth as ISecret;
const project = await ProjectService.secret(sk);
if (!project) {
throw new NotFound("project");
}
const { id } = UtilitySchemas.id.parse(req.body);
const campaign = await CampaignService.id(id);
if (!campaign || campaign.projectId !== project.id) {
throw new NotFound("campaign");
}
await prisma.campaign.delete({ where: { id } });
await redis.del(Keys.Campaign.id(campaign.id));
await redis.del(Keys.Project.campaigns(project.id));
return res.status(200).json(campaign);
}
}