Initial Commit
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
import {
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Middleware,
|
||||
Post,
|
||||
Put,
|
||||
} from "@overnightjs/core";
|
||||
import { ActionSchemas, UtilitySchemas } from "@plunk/shared";
|
||||
import type { Request, Response } from "express";
|
||||
import { prisma } from "../../database/prisma";
|
||||
import { NotFound } from "../../exceptions";
|
||||
import {
|
||||
type IJwt,
|
||||
type ISecret,
|
||||
isAuthenticated,
|
||||
isValidSecretKey,
|
||||
} from "../../middleware/auth";
|
||||
import { ActionService } from "../../services/ActionService";
|
||||
import { EventService } from "../../services/EventService";
|
||||
import { MembershipService } from "../../services/MembershipService";
|
||||
import { ProjectService } from "../../services/ProjectService";
|
||||
import { TemplateService } from "../../services/TemplateService";
|
||||
import { Keys } from "../../services/keys";
|
||||
import { redis } from "../../services/redis";
|
||||
|
||||
@Controller("actions")
|
||||
export class Actions {
|
||||
@Get(":id")
|
||||
@Middleware([isAuthenticated])
|
||||
public async getActionById(req: Request, res: Response) {
|
||||
const { id } = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
const { userId } = res.locals.auth as IJwt;
|
||||
|
||||
const action = await ActionService.id(id);
|
||||
|
||||
if (!action) {
|
||||
throw new NotFound("action");
|
||||
}
|
||||
|
||||
const isMember = await MembershipService.isMember(action.projectId, userId);
|
||||
|
||||
if (!isMember) {
|
||||
throw new NotFound("action");
|
||||
}
|
||||
|
||||
return res.status(200).json(action);
|
||||
}
|
||||
|
||||
@Get(":id/related")
|
||||
@Middleware([isAuthenticated])
|
||||
public async getRelatedActionsById(req: Request, res: Response) {
|
||||
const { id } = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
const { userId } = res.locals.auth as IJwt;
|
||||
|
||||
const action = await ActionService.id(id);
|
||||
|
||||
if (!action) {
|
||||
throw new NotFound("action");
|
||||
}
|
||||
|
||||
const isMember = await MembershipService.isMember(action.projectId, userId);
|
||||
|
||||
if (!isMember) {
|
||||
throw new NotFound("action");
|
||||
}
|
||||
|
||||
const related = await ActionService.related(id);
|
||||
|
||||
return res.status(200).json(related);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@Middleware([isValidSecretKey])
|
||||
public async createAction(req: Request, res: Response) {
|
||||
const { sk } = res.locals.auth as ISecret;
|
||||
|
||||
const project = await ProjectService.secret(sk);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const {
|
||||
name,
|
||||
runOnce,
|
||||
delay,
|
||||
template: templateId,
|
||||
events,
|
||||
notevents,
|
||||
} = ActionSchemas.create.parse(req.body);
|
||||
|
||||
const template = await TemplateService.id(templateId);
|
||||
|
||||
if (!template) {
|
||||
throw new NotFound("template");
|
||||
}
|
||||
|
||||
const action = await prisma.action.create({
|
||||
data: {
|
||||
projectId: project.id,
|
||||
name,
|
||||
runOnce,
|
||||
delay,
|
||||
templateId: template.id,
|
||||
},
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
events.map(async (e: string) => {
|
||||
const event = await EventService.id(e);
|
||||
|
||||
if (!event) {
|
||||
throw new NotFound("event");
|
||||
}
|
||||
|
||||
if (event.projectId !== project.id) {
|
||||
throw new NotFound("event");
|
||||
}
|
||||
|
||||
await prisma.action.update({
|
||||
where: { id: action.id },
|
||||
data: { events: { connect: { id: event.id } } },
|
||||
});
|
||||
}),
|
||||
notevents.map(async (e: string) => {
|
||||
const event = await EventService.id(e);
|
||||
|
||||
if (!event) {
|
||||
throw new NotFound("event");
|
||||
}
|
||||
|
||||
if (event.projectId !== project.id) {
|
||||
throw new NotFound("event");
|
||||
}
|
||||
|
||||
await prisma.action.update({
|
||||
where: { id: action.id },
|
||||
data: { notevents: { connect: { id: event.id } } },
|
||||
});
|
||||
}),
|
||||
]);
|
||||
|
||||
await redis.del(Keys.Action.id(action.id));
|
||||
await redis.del(Keys.Project.actions(project.id));
|
||||
|
||||
return res.status(200).json(action);
|
||||
}
|
||||
|
||||
@Put()
|
||||
@Middleware([isValidSecretKey])
|
||||
public async updateAction(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,
|
||||
template: templateId,
|
||||
events,
|
||||
notevents,
|
||||
name,
|
||||
runOnce,
|
||||
delay,
|
||||
} = ActionSchemas.update.parse(req.body);
|
||||
|
||||
let action = await ActionService.id(id);
|
||||
|
||||
if (!action || action.projectId !== project.id) {
|
||||
throw new NotFound("action");
|
||||
}
|
||||
|
||||
const template = await TemplateService.id(templateId);
|
||||
|
||||
if (!template || template.projectId !== project.id) {
|
||||
throw new NotFound("template");
|
||||
}
|
||||
|
||||
const actionEvents = await prisma.action.findUnique({
|
||||
where: { id },
|
||||
include: { events: true, notevents: true },
|
||||
});
|
||||
|
||||
action = await prisma.action.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name,
|
||||
runOnce,
|
||||
delay,
|
||||
templateId,
|
||||
events: { disconnect: actionEvents?.events.map((e) => ({ id: e.id })) },
|
||||
notevents: {
|
||||
disconnect: actionEvents?.notevents.map((e) => ({ id: e.id })),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
events: true,
|
||||
notevents: true,
|
||||
triggers: true,
|
||||
emails: true,
|
||||
template: true,
|
||||
},
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
events.map(async (e: string) => {
|
||||
const event = await EventService.id(e);
|
||||
|
||||
if (!event || event.projectId !== project.id) {
|
||||
throw new NotFound("event");
|
||||
}
|
||||
|
||||
await prisma.action.update({
|
||||
where: { id },
|
||||
data: { events: { connect: { id: event.id } } },
|
||||
});
|
||||
}),
|
||||
notevents.map(async (e: string) => {
|
||||
const event = await EventService.id(e);
|
||||
|
||||
if (!event || event.projectId !== project.id) {
|
||||
throw new NotFound("event");
|
||||
}
|
||||
|
||||
await prisma.action.update({
|
||||
where: { id },
|
||||
data: { notevents: { connect: { id: event.id } } },
|
||||
});
|
||||
}),
|
||||
]);
|
||||
|
||||
await redis.del(Keys.Action.id(action.id));
|
||||
await redis.del(Keys.Project.actions(project.id));
|
||||
|
||||
return res.status(200).json(action);
|
||||
}
|
||||
|
||||
@Delete()
|
||||
@Middleware([isValidSecretKey])
|
||||
public async deleteAction(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 action = await ActionService.id(id);
|
||||
|
||||
if (!action || action.projectId !== project.id) {
|
||||
throw new NotFound("action");
|
||||
}
|
||||
|
||||
await prisma.action.delete({ where: { id } });
|
||||
|
||||
await redis.del(Keys.Action.id(action.id));
|
||||
await redis.del(Keys.Project.actions(project.id));
|
||||
|
||||
return res.status(200).json(action);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
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, NotFound } from "../../exceptions";
|
||||
import {
|
||||
type IJwt,
|
||||
type ISecret,
|
||||
isAuthenticated,
|
||||
isValidSecretKey,
|
||||
} from "../../middleware/auth";
|
||||
import { CampaignService } from "../../services/CampaignService";
|
||||
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
|
||||
: "[email protected]",
|
||||
},
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
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 } = CampaignSchemas.create.parse(
|
||||
req.body,
|
||||
);
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
const chunkSize = 500;
|
||||
for (let i = 0; i < recipients.length; i += chunkSize) {
|
||||
const chunk = recipients.slice(i, i + chunkSize);
|
||||
|
||||
await prisma.campaign.update({
|
||||
where: { id: campaign.id },
|
||||
data: {
|
||||
recipients: {
|
||||
connect: chunk.map((r: string) => ({ id: r })),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
// eslint-disable-next-line prefer-const
|
||||
let { id, subject, body, recipients, style } = CampaignSchemas.update.parse(
|
||||
req.body,
|
||||
);
|
||||
|
||||
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,
|
||||
},
|
||||
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 < recipients.length; i += chunkSize) {
|
||||
const chunk = recipients.slice(i, i + chunkSize);
|
||||
|
||||
await prisma.campaign.update({
|
||||
where: { id },
|
||||
data: {
|
||||
recipients: {
|
||||
connect: chunk.map((r: string) => ({ id: r })),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
import {
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Middleware,
|
||||
Post,
|
||||
Put,
|
||||
} from "@overnightjs/core";
|
||||
import { ContactSchemas, UtilitySchemas } from "@plunk/shared";
|
||||
import type { Request, Response } from "express";
|
||||
import z from "zod";
|
||||
import { prisma } from "../../database/prisma";
|
||||
import { HttpException, NotFound } from "../../exceptions";
|
||||
import {
|
||||
type IKey,
|
||||
type ISecret,
|
||||
isValidKey,
|
||||
isValidSecretKey,
|
||||
} from "../../middleware/auth";
|
||||
import { ActionService } from "../../services/ActionService";
|
||||
import { ContactService } from "../../services/ContactService";
|
||||
import { EventService } from "../../services/EventService";
|
||||
import { ProjectService } from "../../services/ProjectService";
|
||||
import { Keys } from "../../services/keys";
|
||||
import { redis } from "../../services/redis";
|
||||
|
||||
@Controller("contacts")
|
||||
export class Contacts {
|
||||
@Get("count")
|
||||
@Middleware([isValidKey])
|
||||
public async getContactCount(req: Request, res: Response) {
|
||||
const { key } = res.locals.auth as IKey;
|
||||
|
||||
const project = await ProjectService.key(key);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const count = await ProjectService.contacts.count(project.id);
|
||||
|
||||
return res.status(200).json({ count });
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
public async getContactById(req: Request, res: Response) {
|
||||
const { id } = UtilitySchemas.id.parse(req.params);
|
||||
const { withProject } = z
|
||||
.object({
|
||||
withProject: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.or(z.string().transform((s) => s === "true")),
|
||||
})
|
||||
.parse(req.query);
|
||||
|
||||
const contact = await ContactService.id(id);
|
||||
|
||||
if (!contact) {
|
||||
throw new NotFound("contact");
|
||||
}
|
||||
|
||||
if (withProject) {
|
||||
const project = await ProjectService.id(contact.projectId);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
...contact,
|
||||
project: { name: project.name, public: project.public },
|
||||
});
|
||||
}
|
||||
return res.status(200).json(contact);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@Middleware([isValidSecretKey])
|
||||
public async getContacts(req: Request, res: Response) {
|
||||
const { sk } = res.locals.auth as ISecret;
|
||||
|
||||
const project = await ProjectService.secret(sk);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const contacts = await ProjectService.contacts.get(project.id);
|
||||
|
||||
return res.status(200).json(
|
||||
contacts?.map((c) => {
|
||||
return {
|
||||
id: c.id,
|
||||
email: c.email,
|
||||
subscribed: c.subscribed,
|
||||
data: c.data,
|
||||
createdAt: c.createdAt,
|
||||
updatedAt: c.updatedAt,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@Post("unsubscribe")
|
||||
@Middleware([isValidKey])
|
||||
public async unsubscribe(req: Request, res: Response) {
|
||||
const { key } = res.locals.auth as IKey;
|
||||
|
||||
const project = await ProjectService.key(key);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const { id } = UtilitySchemas.id.parse(req.body);
|
||||
|
||||
const contact = await ContactService.id(id);
|
||||
|
||||
if (!contact || contact.projectId !== project.id) {
|
||||
throw new NotFound("contact");
|
||||
}
|
||||
|
||||
await prisma.contact.update({
|
||||
where: { id },
|
||||
data: { subscribed: false },
|
||||
});
|
||||
|
||||
let event = await EventService.event(project.id, "unsubscribe");
|
||||
|
||||
if (!event) {
|
||||
event = await prisma.event.create({
|
||||
data: { name: "unsubscribe", projectId: project.id },
|
||||
});
|
||||
|
||||
await redis.del(Keys.Project.events(project.id, true));
|
||||
await redis.del(Keys.Project.events(project.id, false));
|
||||
await redis.del(Keys.Event.event(project.id, event.name));
|
||||
await redis.del(Keys.Event.id(event.id));
|
||||
}
|
||||
|
||||
await prisma.trigger.create({
|
||||
data: { eventId: event.id, contactId: contact.id },
|
||||
});
|
||||
await redis.del(Keys.Contact.id(contact.id));
|
||||
|
||||
await ActionService.trigger({ event, contact, project });
|
||||
|
||||
await redis.del(Keys.Project.contacts(project.id));
|
||||
await redis.del(Keys.Contact.id(contact.id));
|
||||
await redis.del(Keys.Contact.email(project.id, contact.email));
|
||||
|
||||
return res
|
||||
.status(200)
|
||||
.json({ success: true, contact: contact.id, subscribed: false });
|
||||
}
|
||||
|
||||
@Post("subscribe")
|
||||
@Middleware([isValidKey])
|
||||
public async subscribe(req: Request, res: Response) {
|
||||
const { key } = res.locals.auth as IKey;
|
||||
|
||||
const project = await ProjectService.key(key);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const { id } = UtilitySchemas.id.parse(req.body);
|
||||
|
||||
const contact = await ContactService.id(id);
|
||||
|
||||
if (!contact || contact.projectId !== project.id) {
|
||||
throw new NotFound("contact");
|
||||
}
|
||||
|
||||
await prisma.contact.update({
|
||||
where: { id },
|
||||
data: { subscribed: true },
|
||||
});
|
||||
|
||||
let event = await EventService.event(project.id, "subscribe");
|
||||
|
||||
if (!event) {
|
||||
event = await prisma.event.create({
|
||||
data: { name: "subscribe", projectId: project.id },
|
||||
});
|
||||
|
||||
await redis.del(Keys.Project.events(project.id, true));
|
||||
await redis.del(Keys.Project.events(project.id, false));
|
||||
await redis.del(Keys.Event.event(project.id, event.name));
|
||||
await redis.del(Keys.Event.id(event.id));
|
||||
}
|
||||
|
||||
await prisma.trigger.create({
|
||||
data: { eventId: event.id, contactId: contact.id },
|
||||
});
|
||||
await redis.del(Keys.Contact.id(contact.id));
|
||||
|
||||
await ActionService.trigger({ event, contact, project });
|
||||
|
||||
await redis.del(Keys.Project.contacts(project.id));
|
||||
await redis.del(Keys.Contact.id(contact.id));
|
||||
await redis.del(Keys.Contact.email(project.id, contact.email));
|
||||
|
||||
return res
|
||||
.status(200)
|
||||
.json({ success: true, contact: contact.id, subscribed: true });
|
||||
}
|
||||
|
||||
@Post()
|
||||
@Middleware([isValidSecretKey])
|
||||
public async createContact(req: Request, res: Response) {
|
||||
const { sk } = res.locals.auth as ISecret;
|
||||
|
||||
const project = await ProjectService.secret(sk);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const { email, subscribed, data } = ContactSchemas.create.parse(req.body);
|
||||
|
||||
let contact = await ContactService.email(project.id, email);
|
||||
|
||||
if (contact) {
|
||||
throw new HttpException(409, "Contact already exists");
|
||||
}
|
||||
|
||||
contact = await prisma.contact.create({
|
||||
data: {
|
||||
projectId: project.id,
|
||||
email,
|
||||
subscribed,
|
||||
data: data ? JSON.stringify(data) : null,
|
||||
},
|
||||
});
|
||||
|
||||
await redis.del(Keys.Project.contacts(project.id));
|
||||
await redis.del(Keys.Contact.id(contact.id));
|
||||
await redis.del(Keys.Contact.email(project.id, email));
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
id: contact.id,
|
||||
email: contact.email,
|
||||
subscribed: contact.subscribed,
|
||||
data: contact.data,
|
||||
createdAt: contact.createdAt,
|
||||
updatedAt: contact.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
@Put()
|
||||
@Middleware([isValidSecretKey])
|
||||
public async updateContact(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, email, subscribed, data } = ContactSchemas.update.parse(
|
||||
req.body,
|
||||
);
|
||||
|
||||
let contact = await ContactService.id(id);
|
||||
|
||||
if (!contact || contact.projectId !== project.id) {
|
||||
throw new NotFound("contact");
|
||||
}
|
||||
|
||||
contact = await prisma.contact.update({
|
||||
where: { id },
|
||||
data: { email, subscribed, data: data ? JSON.stringify(data) : null },
|
||||
include: {
|
||||
triggers: { include: { event: true, action: true } },
|
||||
emails: { where: { subject: { not: null } } },
|
||||
},
|
||||
});
|
||||
|
||||
await redis.del(Keys.Project.contacts(project.id));
|
||||
await redis.del(Keys.Contact.id(contact.id));
|
||||
await redis.del(Keys.Contact.email(project.id, email));
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
id: contact.id,
|
||||
email: contact.email,
|
||||
subscribed: contact.subscribed,
|
||||
data: contact.data,
|
||||
createdAt: contact.createdAt,
|
||||
updatedAt: contact.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
@Delete()
|
||||
@Middleware([isValidSecretKey])
|
||||
public async deleteContact(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 contact = await ContactService.id(id);
|
||||
|
||||
if (!contact || contact.projectId !== project.id) {
|
||||
throw new NotFound("contact");
|
||||
}
|
||||
|
||||
await prisma.contact.delete({ where: { id } });
|
||||
|
||||
await redis.del(Keys.Project.contacts(project.id));
|
||||
await redis.del(Keys.Contact.id(contact.id));
|
||||
await redis.del(Keys.Contact.email(project.id, contact.email));
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
id: contact.id,
|
||||
email: contact.email,
|
||||
subscribed: contact.subscribed,
|
||||
data: contact.data,
|
||||
createdAt: contact.createdAt,
|
||||
updatedAt: contact.updatedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Controller, Delete, Middleware } from "@overnightjs/core";
|
||||
import { UtilitySchemas } from "@plunk/shared";
|
||||
import type { Request, Response } from "express";
|
||||
import { prisma } from "../../database/prisma";
|
||||
import { NotFound } from "../../exceptions";
|
||||
import { type ISecret, isValidSecretKey } from "../../middleware/auth";
|
||||
import { EventService } from "../../services/EventService";
|
||||
import { ProjectService } from "../../services/ProjectService";
|
||||
import { Keys } from "../../services/keys";
|
||||
import { redis } from "../../services/redis";
|
||||
|
||||
@Controller("events")
|
||||
export class Events {
|
||||
@Delete()
|
||||
@Middleware([isValidSecretKey])
|
||||
public async deleteEvent(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 event = await EventService.id(id);
|
||||
|
||||
if (!event || event.projectId !== project.id) {
|
||||
throw new NotFound("event");
|
||||
}
|
||||
|
||||
await prisma.event.delete({ where: { id } });
|
||||
|
||||
await redis.del(Keys.Event.id(id));
|
||||
|
||||
return res.status(200).json(event);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import {
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Middleware,
|
||||
Post,
|
||||
Put,
|
||||
} from "@overnightjs/core";
|
||||
import { TemplateSchemas, UtilitySchemas } from "@plunk/shared";
|
||||
import type { Request, Response } from "express";
|
||||
import { prisma } from "../../database/prisma";
|
||||
import { NotAllowed, NotFound } from "../../exceptions";
|
||||
import {
|
||||
type IJwt,
|
||||
type ISecret,
|
||||
isAuthenticated,
|
||||
isValidSecretKey,
|
||||
} from "../../middleware/auth";
|
||||
import { MembershipService } from "../../services/MembershipService";
|
||||
import { ProjectService } from "../../services/ProjectService";
|
||||
import { TemplateService } from "../../services/TemplateService";
|
||||
import { Keys } from "../../services/keys";
|
||||
import { redis } from "../../services/redis";
|
||||
|
||||
@Controller("templates")
|
||||
export class Templates {
|
||||
@Get(":id")
|
||||
@Middleware([isAuthenticated])
|
||||
public async getTemplateById(req: Request, res: Response) {
|
||||
const { id } = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
const { userId } = res.locals.auth as IJwt;
|
||||
|
||||
const template = await TemplateService.id(id);
|
||||
|
||||
if (!template) {
|
||||
throw new NotFound("template");
|
||||
}
|
||||
|
||||
const isMember = await MembershipService.isMember(
|
||||
template.projectId,
|
||||
userId,
|
||||
);
|
||||
|
||||
if (!isMember) {
|
||||
throw new NotFound("template");
|
||||
}
|
||||
|
||||
return res.status(200).json(template);
|
||||
}
|
||||
|
||||
@Post("duplicate")
|
||||
@Middleware([isValidSecretKey])
|
||||
public async duplicateTemplate(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 template = await TemplateService.id(id);
|
||||
|
||||
if (!template || template.projectId !== project.id) {
|
||||
throw new NotFound("template");
|
||||
}
|
||||
|
||||
const duplicatedTemplate = await prisma.template.create({
|
||||
data: {
|
||||
projectId: project.id,
|
||||
subject: template.subject,
|
||||
body: template.body,
|
||||
type: template.type,
|
||||
style: template.style,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.event.createMany({
|
||||
data: [
|
||||
{
|
||||
projectId: project.id,
|
||||
name: `${duplicatedTemplate.subject
|
||||
.toLowerCase()
|
||||
.replace(/[.,/#!$%^&*;:{}=\-_`~()]/g, "")
|
||||
.replace(/ /g, "-")}-template-delivered`,
|
||||
templateId: template.id,
|
||||
},
|
||||
{
|
||||
projectId: project.id,
|
||||
name: `${duplicatedTemplate.subject
|
||||
.toLowerCase()
|
||||
.replace(/[.,/#!$%^&*;:{}=\-_`~()]/g, "")
|
||||
.replace(/ /g, "-")}-template-opened`,
|
||||
templateId: template.id,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await redis.del(Keys.Project.templates(project.id));
|
||||
await redis.del(Keys.Template.id(template.id));
|
||||
|
||||
return res.status(200).json(template);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@Middleware([isValidSecretKey])
|
||||
public async createTemplate(req: Request, res: Response) {
|
||||
const { sk } = res.locals.auth as ISecret;
|
||||
|
||||
const project = await ProjectService.secret(sk);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const { subject, body, type, style } = TemplateSchemas.create.parse(
|
||||
req.body,
|
||||
);
|
||||
|
||||
const template = await prisma.template.create({
|
||||
data: {
|
||||
projectId: project.id,
|
||||
subject,
|
||||
body,
|
||||
type,
|
||||
style,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.event.createMany({
|
||||
data: [
|
||||
{
|
||||
projectId: project.id,
|
||||
name: `${subject
|
||||
.toLowerCase()
|
||||
.replace(/[.,/#!$%^&*;:{}=\-_`~()]/g, "")
|
||||
.replace(/ /g, "-")}-template-delivered`,
|
||||
templateId: template.id,
|
||||
},
|
||||
{
|
||||
projectId: project.id,
|
||||
name: `${subject
|
||||
.toLowerCase()
|
||||
.replace(/[.,/#!$%^&*;:{}=\-_`~()]/g, "")
|
||||
.replace(/ /g, "-")}-template-opened`,
|
||||
templateId: template.id,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await redis.del(Keys.Project.templates(project.id));
|
||||
await redis.del(Keys.Template.id(template.id));
|
||||
|
||||
return res.status(200).json(template);
|
||||
}
|
||||
|
||||
@Put()
|
||||
@Middleware([isValidSecretKey])
|
||||
public async updateTemplate(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, subject, body, type, style } = TemplateSchemas.update.parse(
|
||||
req.body,
|
||||
);
|
||||
|
||||
let template = await TemplateService.id(id);
|
||||
|
||||
if (!template || template.projectId !== project.id) {
|
||||
throw new NotFound("template");
|
||||
}
|
||||
|
||||
template = await prisma.template.update({
|
||||
where: { id },
|
||||
data: { subject, body, type, style },
|
||||
include: {
|
||||
actions: true,
|
||||
},
|
||||
});
|
||||
|
||||
const events = await prisma.event.findMany({
|
||||
where: { templateId: template.id },
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
events.map(async (e) => {
|
||||
await prisma.event.update({
|
||||
where: { id: e.id },
|
||||
data: {
|
||||
name: e.name.includes("delivered")
|
||||
? `${subject
|
||||
.toLowerCase()
|
||||
.replace(/[.,/#!$%^&*;:{}=\-_`~()]/g, "")
|
||||
.replace(/ /g, "-")}-template-delivered`
|
||||
: `${subject
|
||||
.toLowerCase()
|
||||
.replace(/[.,/#!$%^&*;:{}=\-_`~()]/g, "")
|
||||
.replace(/ /g, "-")}-template-opened`,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
await redis.del(Keys.Project.templates(project.id));
|
||||
await redis.del(Keys.Template.id(template.id));
|
||||
|
||||
return res.status(200).json(template);
|
||||
}
|
||||
|
||||
@Delete()
|
||||
@Middleware([isValidSecretKey])
|
||||
public async deleteTemplate(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 template = await TemplateService.id(id);
|
||||
|
||||
if (!template || template.projectId !== project.id) {
|
||||
throw new NotFound("template");
|
||||
}
|
||||
|
||||
const actions = await TemplateService.actions(id);
|
||||
|
||||
if (actions && actions.length > 0) {
|
||||
throw new NotAllowed(
|
||||
"This template is being used by an action. Unlink the action before deleting the template.",
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.template.delete({ where: { id } });
|
||||
|
||||
await redis.del(Keys.Project.templates(project.id));
|
||||
await redis.del(Keys.Template.id(template.id));
|
||||
|
||||
return res.status(200).json(template);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
import {
|
||||
ChildControllers,
|
||||
Controller,
|
||||
Middleware,
|
||||
Post,
|
||||
} from "@overnightjs/core";
|
||||
import { EventSchemas } from "@plunk/shared";
|
||||
import dayjs from "dayjs";
|
||||
import type { Request, Response } from "express";
|
||||
import signale from "signale";
|
||||
import { prisma } from "../../database/prisma";
|
||||
import { HttpException, NotAllowed } from "../../exceptions";
|
||||
import {
|
||||
type IKey,
|
||||
type ISecret,
|
||||
isValidKey,
|
||||
isValidSecretKey,
|
||||
} from "../../middleware/auth";
|
||||
import { ActionService } from "../../services/ActionService";
|
||||
import { ContactService } from "../../services/ContactService";
|
||||
import { EmailService } from "../../services/EmailService";
|
||||
import { EventService } from "../../services/EventService";
|
||||
import { ProjectService } from "../../services/ProjectService";
|
||||
import { Keys } from "../../services/keys";
|
||||
import { redis } from "../../services/redis";
|
||||
import { Actions } from "./Actions";
|
||||
import { Campaigns } from "./Campaigns";
|
||||
import { Contacts } from "./Contacts";
|
||||
import { Events } from "./Events";
|
||||
import { Templates } from "./Templates";
|
||||
|
||||
@Controller("v1")
|
||||
@ChildControllers([
|
||||
new Actions(),
|
||||
new Templates(),
|
||||
new Campaigns(),
|
||||
new Contacts(),
|
||||
new Events(),
|
||||
])
|
||||
export class V1 {
|
||||
@Post()
|
||||
@Post("track")
|
||||
@Middleware([isValidKey])
|
||||
public async postEvent(req: Request, res: Response) {
|
||||
const { key } = res.locals.auth as IKey;
|
||||
|
||||
const project = await ProjectService.key(key);
|
||||
|
||||
if (!project) {
|
||||
throw new HttpException(401, "Incorrect Bearer token specified");
|
||||
}
|
||||
|
||||
const result = EventSchemas.post.safeParse(req.body);
|
||||
|
||||
if (!result.success) {
|
||||
signale.warn(
|
||||
`${project.name} tried tracking an event with invalid data: ${JSON.stringify(req.body)}`,
|
||||
);
|
||||
if ("unionErrors" in result.error.issues[0]) {
|
||||
throw new HttpException(
|
||||
400,
|
||||
result.error.issues[0].unionErrors[0].errors[0].message,
|
||||
);
|
||||
}
|
||||
|
||||
throw new HttpException(400, result.error.issues[0].message);
|
||||
}
|
||||
|
||||
const { event: name, email, data, subscribed } = result.data;
|
||||
|
||||
if (name === "subscribe" || name === "unsubscribe") {
|
||||
throw new NotAllowed("subscribe & unsubscribe are reserved event names.");
|
||||
}
|
||||
|
||||
let event = await EventService.event(project.id, name);
|
||||
|
||||
if (!event) {
|
||||
event = await prisma.event.create({
|
||||
data: { name, projectId: project.id },
|
||||
});
|
||||
redis.set(
|
||||
Keys.Event.event(project.id, event.name),
|
||||
JSON.stringify(event),
|
||||
);
|
||||
redis.set(Keys.Event.id(event.id), JSON.stringify(event));
|
||||
|
||||
redis.del(Keys.Project.events(project.id, true));
|
||||
redis.del(Keys.Project.events(project.id, false));
|
||||
}
|
||||
|
||||
let contact = await ContactService.email(project.id, email);
|
||||
|
||||
if (!contact) {
|
||||
contact = await prisma.contact.create({
|
||||
data: {
|
||||
email,
|
||||
subscribed: subscribed ?? true,
|
||||
projectId: project.id,
|
||||
},
|
||||
});
|
||||
|
||||
redis.del(Keys.Contact.id(contact.id));
|
||||
redis.del(Keys.Contact.email(project.id, contact.email));
|
||||
} else {
|
||||
if (subscribed && contact.subscribed !== subscribed) {
|
||||
contact = await prisma.contact.update({
|
||||
where: { id: contact.id },
|
||||
data: { subscribed },
|
||||
});
|
||||
|
||||
redis.del(Keys.Contact.id(contact.id));
|
||||
redis.del(Keys.Contact.email(project.id, contact.email));
|
||||
}
|
||||
}
|
||||
|
||||
if (data) {
|
||||
const givenUserData = Object.entries(data);
|
||||
const userData = JSON.parse(contact.data ?? "{}");
|
||||
const dataToUpdate = JSON.parse(contact.data ?? "{}");
|
||||
|
||||
givenUserData.forEach(([key, value]) => {
|
||||
userData[key] = value.value;
|
||||
if (value.persistent) {
|
||||
dataToUpdate[key] = value.value;
|
||||
}
|
||||
});
|
||||
|
||||
contact.data = JSON.stringify(userData);
|
||||
|
||||
await prisma.contact.update({
|
||||
where: { id: contact.id },
|
||||
data: { data: JSON.stringify(dataToUpdate) },
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.trigger.create({
|
||||
data: { eventId: event.id, contactId: contact.id },
|
||||
});
|
||||
|
||||
void ActionService.trigger({ event, contact, project });
|
||||
|
||||
signale.success(
|
||||
`${project.name} triggered ${event.name} for ${contact.email}`,
|
||||
);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
contact: contact.id,
|
||||
event: event.id,
|
||||
timestamp: dayjs().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
@Post("send")
|
||||
@Middleware([isValidSecretKey])
|
||||
public async send(req: Request, res: Response) {
|
||||
const { sk } = res.locals.auth as ISecret;
|
||||
|
||||
const project = await ProjectService.secret(sk);
|
||||
|
||||
if (!project) {
|
||||
throw new HttpException(401, "Incorrect Bearer token specified");
|
||||
}
|
||||
|
||||
const result = EventSchemas.send.safeParse(req.body);
|
||||
|
||||
if (!result.success) {
|
||||
if ("unionErrors" in result.error.issues[0]) {
|
||||
throw new HttpException(
|
||||
400,
|
||||
result.error.issues[0].unionErrors[0].errors[0].message,
|
||||
);
|
||||
}
|
||||
|
||||
throw new HttpException(400, result.error.issues[0].message);
|
||||
}
|
||||
|
||||
const { from, name, reply, to, subject, body, subscribed, headers } =
|
||||
result.data;
|
||||
|
||||
if (!project.email || !project.verified) {
|
||||
throw new HttpException(
|
||||
401,
|
||||
"Verify your domain before you start sending",
|
||||
);
|
||||
}
|
||||
|
||||
if (from && from.split("@")[1] !== project.email?.split("@")[1]) {
|
||||
throw new HttpException(
|
||||
401,
|
||||
"Custom from address must be from a verified domain",
|
||||
);
|
||||
}
|
||||
|
||||
const emails: {
|
||||
contact: {
|
||||
id: string;
|
||||
email: string;
|
||||
};
|
||||
email: string;
|
||||
}[] = [];
|
||||
|
||||
for (const email of to) {
|
||||
let contact = await ContactService.email(project.id, email);
|
||||
|
||||
if (!contact) {
|
||||
contact = await prisma.contact.create({
|
||||
data: {
|
||||
email,
|
||||
subscribed: subscribed ?? false,
|
||||
projectId: project.id,
|
||||
},
|
||||
});
|
||||
|
||||
redis.del(Keys.Contact.id(contact.id));
|
||||
redis.del(Keys.Contact.email(project.id, contact.email));
|
||||
} else {
|
||||
if (subscribed && contact.subscribed !== subscribed) {
|
||||
await prisma.contact.update({
|
||||
where: { id: contact.id },
|
||||
data: { subscribed },
|
||||
});
|
||||
redis.set(
|
||||
Keys.Contact.email(project.id, contact.email),
|
||||
JSON.stringify({
|
||||
...contact,
|
||||
subscribed,
|
||||
}),
|
||||
);
|
||||
redis.del(Keys.Contact.id(contact.id));
|
||||
}
|
||||
}
|
||||
|
||||
const { subject: enrichedSubject, body: enrichedBody } =
|
||||
EmailService.format({
|
||||
subject,
|
||||
body,
|
||||
data: {
|
||||
plunk_id: contact.id,
|
||||
plunk_email: contact.email,
|
||||
...JSON.parse(contact.data ?? "{}"),
|
||||
},
|
||||
});
|
||||
|
||||
const { messageId } = await EmailService.send({
|
||||
from: {
|
||||
name: name ?? project.from ?? project.name,
|
||||
email: from ?? project.email,
|
||||
},
|
||||
reply: reply ?? from ?? project.email,
|
||||
to: [email],
|
||||
headers,
|
||||
content: {
|
||||
subject: enrichedSubject,
|
||||
html: EmailService.compile({
|
||||
isHtml: true,
|
||||
content: enrichedBody,
|
||||
footer: {
|
||||
unsubscribe: false,
|
||||
},
|
||||
contact: {
|
||||
id: contact.id,
|
||||
},
|
||||
project: {
|
||||
name: project.name,
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const createdEmail = await prisma.email.create({
|
||||
data: {
|
||||
messageId,
|
||||
subject,
|
||||
body: enrichedBody,
|
||||
contactId: contact.id,
|
||||
projectId: project.id,
|
||||
},
|
||||
});
|
||||
|
||||
emails.push({
|
||||
contact: { id: contact.id, email: contact.email },
|
||||
email: createdEmail.id,
|
||||
});
|
||||
}
|
||||
|
||||
redis.del(Keys.Project.emails(project.id));
|
||||
redis.del(Keys.Project.emails(project.id, { count: true }));
|
||||
|
||||
signale.success(
|
||||
`${project.name} sent a transactional email to ${to.join(", ")}`,
|
||||
);
|
||||
|
||||
return res
|
||||
.status(200)
|
||||
.json({ success: true, emails, timestamp: dayjs().toISOString() });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user