Initial Commit
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
import { Controller, Get, Post } from "@overnightjs/core";
|
||||
import { UserSchemas, UtilitySchemas } from "@plunk/shared";
|
||||
import type { Request, Response } from "express";
|
||||
import { prisma } from "../database/prisma";
|
||||
import { NotAllowed, NotFound } from "../exceptions";
|
||||
import { jwt } from "../middleware/auth";
|
||||
import { AuthService } from "../services/AuthService";
|
||||
import { UserService } from "../services/UserService";
|
||||
import { Keys } from "../services/keys";
|
||||
import { REDIS_ONE_MINUTE, redis } from "../services/redis";
|
||||
import { createHash } from "../util/hash";
|
||||
|
||||
@Controller("auth")
|
||||
export class Auth {
|
||||
@Post("login")
|
||||
public async login(req: Request, res: Response) {
|
||||
const { email, password } = UserSchemas.credentials.parse(req.body);
|
||||
|
||||
const user = await UserService.email(email);
|
||||
|
||||
if (!user) {
|
||||
return res.json({ success: false, data: "Incorrect email or password" });
|
||||
}
|
||||
|
||||
if (!user.password) {
|
||||
return res.json({
|
||||
success: "redirect",
|
||||
redirect: `/auth/reset?id=${user.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
const verified = await AuthService.verifyCredentials(email, password);
|
||||
|
||||
if (!verified) {
|
||||
return res.json({ success: false, data: "Incorrect email or password" });
|
||||
}
|
||||
|
||||
await redis.set(
|
||||
Keys.User.id(user.id),
|
||||
JSON.stringify(user),
|
||||
"EX",
|
||||
REDIS_ONE_MINUTE * 60,
|
||||
);
|
||||
|
||||
const token = jwt.sign(user.id);
|
||||
const cookie = UserService.cookieOptions();
|
||||
|
||||
return res
|
||||
.cookie(UserService.COOKIE_NAME, token, cookie)
|
||||
.json({ success: true, data: { id: user.id, email: user.email } });
|
||||
}
|
||||
|
||||
@Post("signup")
|
||||
public async signup(req: Request, res: Response) {
|
||||
const { email, password } = UserSchemas.credentials.parse(req.body);
|
||||
|
||||
const user = await UserService.email(email);
|
||||
|
||||
if (user) {
|
||||
return res.json({
|
||||
success: false,
|
||||
data: "That email is already associated with another user",
|
||||
});
|
||||
}
|
||||
|
||||
const created_user = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
password: await createHash(password),
|
||||
},
|
||||
});
|
||||
|
||||
await redis.set(
|
||||
Keys.User.id(created_user.id),
|
||||
JSON.stringify(created_user),
|
||||
"EX",
|
||||
REDIS_ONE_MINUTE * 60,
|
||||
);
|
||||
|
||||
const token = jwt.sign(created_user.id);
|
||||
const cookie = UserService.cookieOptions();
|
||||
|
||||
return res.cookie(UserService.COOKIE_NAME, token, cookie).json({
|
||||
success: true,
|
||||
data: { id: created_user.id, email: created_user.email },
|
||||
});
|
||||
}
|
||||
|
||||
@Post("reset")
|
||||
public async reset(req: Request, res: Response) {
|
||||
const { id, password } = UtilitySchemas.id
|
||||
.merge(UserSchemas.credentials.pick({ password: true }))
|
||||
.parse(req.body);
|
||||
|
||||
const user = await UserService.id(id);
|
||||
|
||||
if (!user) {
|
||||
throw new NotFound("user");
|
||||
}
|
||||
|
||||
if (user.password) {
|
||||
throw new NotAllowed();
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id },
|
||||
data: { password: await createHash(password) },
|
||||
});
|
||||
|
||||
await redis.del(Keys.User.id(user.id));
|
||||
await redis.del(Keys.User.email(user.email));
|
||||
|
||||
return res.json({ success: true });
|
||||
}
|
||||
|
||||
@Get("logout")
|
||||
public logout(req: Request, res: Response) {
|
||||
res.cookie(
|
||||
UserService.COOKIE_NAME,
|
||||
"",
|
||||
UserService.cookieOptions(new Date()),
|
||||
);
|
||||
return res.json(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { Controller, Get, Middleware, Post } from "@overnightjs/core";
|
||||
import { IdentitySchemas, UtilitySchemas } from "@plunk/shared";
|
||||
import type { Request, Response } from "express";
|
||||
import signale from "signale";
|
||||
import { prisma } from "../database/prisma";
|
||||
import { NotFound } from "../exceptions";
|
||||
import { type IJwt, isAuthenticated } from "../middleware/auth";
|
||||
import { ProjectService } from "../services/ProjectService";
|
||||
import { Keys } from "../services/keys";
|
||||
import { redis } from "../services/redis";
|
||||
import {
|
||||
getIdentities,
|
||||
getIdentityVerificationAttributes,
|
||||
ses,
|
||||
verifyIdentity,
|
||||
} from "../util/ses";
|
||||
|
||||
@Controller("identities")
|
||||
export class Identities {
|
||||
@Get("id/:id")
|
||||
@Middleware([isAuthenticated])
|
||||
public async getVerification(req: Request, res: Response) {
|
||||
const { id } = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
const project = await ProjectService.id(id);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
if (!project.email) {
|
||||
return res.status(200).json({ success: false });
|
||||
}
|
||||
|
||||
const attributes = await getIdentityVerificationAttributes(project.email);
|
||||
|
||||
if (attributes.status === "Success" && !project.verified) {
|
||||
await prisma.project.update({ where: { id }, data: { verified: true } });
|
||||
|
||||
await redis.del(Keys.Project.id(project.id));
|
||||
await redis.del(Keys.Project.secret(project.secret));
|
||||
await redis.del(Keys.Project.public(project.public));
|
||||
}
|
||||
|
||||
return res.status(200).json({ tokens: attributes.tokens });
|
||||
}
|
||||
|
||||
@Middleware([isAuthenticated])
|
||||
@Post("create")
|
||||
public async addIdentity(req: Request, res: Response) {
|
||||
const { id, email } = IdentitySchemas.create.parse(req.body);
|
||||
|
||||
const { userId } = res.locals.auth as IJwt;
|
||||
|
||||
const project = await ProjectService.id(id);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const existingProject = await prisma.project.findFirst({
|
||||
where: { email: { endsWith: email.split("@")[1] } },
|
||||
});
|
||||
|
||||
if (existingProject) {
|
||||
throw new Error("Domain already attached to another project");
|
||||
}
|
||||
|
||||
const tokens = await verifyIdentity(email);
|
||||
|
||||
await prisma.project.update({
|
||||
where: { id },
|
||||
data: { email, verified: false },
|
||||
});
|
||||
|
||||
await redis.del(Keys.User.projects(userId));
|
||||
await redis.del(Keys.Project.id(project.id));
|
||||
|
||||
return res.status(200).json({ success: true, tokens });
|
||||
}
|
||||
|
||||
@Middleware([isAuthenticated])
|
||||
@Post("reset")
|
||||
public async resetIdentity(req: Request, res: Response) {
|
||||
const { id } = UtilitySchemas.id.parse(req.body);
|
||||
|
||||
const { userId } = res.locals.auth as IJwt;
|
||||
|
||||
const project = await ProjectService.id(id);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
await prisma.project.update({
|
||||
where: { id },
|
||||
data: { email: null, verified: false },
|
||||
});
|
||||
|
||||
await redis.del(Keys.User.projects(userId));
|
||||
await redis.del(Keys.Project.id(project.id));
|
||||
|
||||
return res.status(200).json({ success: true });
|
||||
}
|
||||
|
||||
@Post("update")
|
||||
public async updateIdentities(req: Request, res: Response) {
|
||||
const count = await prisma.project.count({
|
||||
where: { email: { not: null } },
|
||||
});
|
||||
|
||||
for (let i = 0; i < count; i += 99) {
|
||||
const dbIdentities = await prisma.project.findMany({
|
||||
where: { email: { not: null } },
|
||||
select: { id: true, email: true },
|
||||
skip: i,
|
||||
take: 99,
|
||||
});
|
||||
|
||||
const awsIdentities = await getIdentities(
|
||||
dbIdentities.map((i) => i.email as string),
|
||||
);
|
||||
|
||||
for (const identity of awsIdentities) {
|
||||
const projectId = dbIdentities.find((i) =>
|
||||
i.email?.endsWith(identity.email),
|
||||
);
|
||||
|
||||
const project = await ProjectService.id(projectId?.id as string);
|
||||
|
||||
if (identity.status === "Failed") {
|
||||
signale.info(`Restarting verification for ${identity.email}`);
|
||||
try {
|
||||
void verifyIdentity(identity.email);
|
||||
} catch (e) {
|
||||
// @ts-ignore
|
||||
if (e.Code === "Throttling") {
|
||||
signale.warn("Throttling detected, waiting 5 seconds");
|
||||
await new Promise((r) => setTimeout(r, 5000));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.project.update({
|
||||
where: { id: projectId?.id as string },
|
||||
data: { verified: identity.status === "Success" },
|
||||
});
|
||||
|
||||
if (project && !project.verified && identity.status === "Success") {
|
||||
signale.success(`Successfully verified ${identity.email}`);
|
||||
void ses.setIdentityFeedbackForwardingEnabled({
|
||||
Identity: identity.email,
|
||||
ForwardingEnabled: false,
|
||||
});
|
||||
|
||||
await redis.del(Keys.Project.id(project.id));
|
||||
await redis.del(Keys.Project.secret(project.secret));
|
||||
await redis.del(Keys.Project.public(project.public));
|
||||
}
|
||||
|
||||
if (project?.verified && identity.status !== "Success") {
|
||||
await redis.del(Keys.Project.id(project.id));
|
||||
await redis.del(Keys.Project.secret(project.secret));
|
||||
await redis.del(Keys.Project.public(project.public));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(200).json({ success: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { Controller, Middleware, Post } from "@overnightjs/core";
|
||||
import { MembershipSchemas, UtilitySchemas } from "@plunk/shared";
|
||||
import type { Request, Response } from "express";
|
||||
import {
|
||||
HttpException,
|
||||
NotAllowed,
|
||||
NotAuthenticated,
|
||||
NotFound,
|
||||
} from "../exceptions";
|
||||
import { type IJwt, isAuthenticated } from "../middleware/auth";
|
||||
import { MembershipService } from "../services/MembershipService";
|
||||
import { ProjectService } from "../services/ProjectService";
|
||||
import { UserService } from "../services/UserService";
|
||||
import { Keys } from "../services/keys";
|
||||
import { redis } from "../services/redis";
|
||||
|
||||
@Controller("memberships")
|
||||
export class Memberships {
|
||||
@Middleware([isAuthenticated])
|
||||
@Post("invite")
|
||||
public async inviteMember(req: Request, res: Response) {
|
||||
const { id: projectId, email } = MembershipSchemas.invite.parse(req.body);
|
||||
|
||||
const { userId } = res.locals.auth as IJwt;
|
||||
|
||||
const project = await ProjectService.id(projectId);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const isAdmin = await MembershipService.isAdmin(projectId, userId);
|
||||
|
||||
if (!isAdmin) {
|
||||
throw new NotAllowed();
|
||||
}
|
||||
|
||||
const invitedUser = await UserService.email(email);
|
||||
|
||||
if (!invitedUser) {
|
||||
throw new HttpException(
|
||||
404,
|
||||
"We could not find that user, please ask them to sign up first.",
|
||||
);
|
||||
}
|
||||
|
||||
const alreadyMember = await MembershipService.isMember(
|
||||
project.id,
|
||||
invitedUser.id,
|
||||
);
|
||||
|
||||
if (alreadyMember) {
|
||||
throw new NotAllowed();
|
||||
}
|
||||
|
||||
await MembershipService.invite(projectId, invitedUser.id, "ADMIN");
|
||||
|
||||
const memberships = await ProjectService.memberships(projectId);
|
||||
|
||||
return res.status(200).json({ success: true, memberships });
|
||||
}
|
||||
|
||||
@Middleware([isAuthenticated])
|
||||
@Post("kick")
|
||||
public async kickMember(req: Request, res: Response) {
|
||||
const { id: projectId, email } = MembershipSchemas.kick.parse(req.body);
|
||||
|
||||
const { userId } = res.locals.auth as IJwt;
|
||||
|
||||
const user = await UserService.id(userId);
|
||||
|
||||
if (!user) {
|
||||
throw new NotAuthenticated();
|
||||
}
|
||||
|
||||
const project = await ProjectService.id(projectId);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const isAdmin = await MembershipService.isAdmin(projectId, userId);
|
||||
|
||||
if (!isAdmin) {
|
||||
throw new NotAllowed();
|
||||
}
|
||||
|
||||
const kickedUser = await UserService.email(email);
|
||||
|
||||
if (!kickedUser) {
|
||||
throw new NotFound("user");
|
||||
}
|
||||
|
||||
const isMember = await MembershipService.isMember(
|
||||
project.id,
|
||||
kickedUser.id,
|
||||
);
|
||||
|
||||
if (!isMember) {
|
||||
throw new NotAllowed();
|
||||
}
|
||||
|
||||
if (userId === kickedUser.id) {
|
||||
throw new NotAllowed();
|
||||
}
|
||||
|
||||
await MembershipService.kick(projectId, kickedUser.id);
|
||||
|
||||
const memberships = await ProjectService.memberships(projectId);
|
||||
|
||||
return res.status(200).json({ success: true, memberships });
|
||||
}
|
||||
|
||||
@Middleware([isAuthenticated])
|
||||
@Post("leave")
|
||||
public async leaveProject(req: Request, res: Response) {
|
||||
const { id: projectId } = UtilitySchemas.id.parse(req.body);
|
||||
|
||||
const { userId } = res.locals.auth as IJwt;
|
||||
|
||||
const user = await UserService.id(userId);
|
||||
|
||||
if (!user) {
|
||||
throw new NotAuthenticated();
|
||||
}
|
||||
|
||||
const project = await ProjectService.id(projectId);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const isMember = await MembershipService.isMember(projectId, userId);
|
||||
|
||||
if (!isMember) {
|
||||
throw new NotAllowed();
|
||||
}
|
||||
|
||||
await MembershipService.kick(projectId, userId);
|
||||
|
||||
await redis.del(Keys.User.projects(userId));
|
||||
|
||||
const memberships = await UserService.projects(userId);
|
||||
|
||||
return res.status(200).json({ success: true, memberships });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,624 @@
|
||||
import {
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Middleware,
|
||||
Post,
|
||||
Put,
|
||||
} from "@overnightjs/core";
|
||||
import { IdentitySchemas, ProjectSchemas, UtilitySchemas } from "@plunk/shared";
|
||||
import type { Request, Response } from "express";
|
||||
import z from "zod";
|
||||
import { prisma } from "../database/prisma";
|
||||
import { NotAllowed, NotAuthenticated, NotFound } from "../exceptions";
|
||||
import { type IJwt, isAuthenticated } from "../middleware/auth";
|
||||
import { MembershipService } from "../services/MembershipService";
|
||||
import { ProjectService } from "../services/ProjectService";
|
||||
import { UserService } from "../services/UserService";
|
||||
import { Keys } from "../services/keys";
|
||||
import { redis } from "../services/redis";
|
||||
import { generateToken } from "../util/tokens";
|
||||
|
||||
@Controller("projects")
|
||||
export class Projects {
|
||||
@Get("id/:id")
|
||||
@Middleware([isAuthenticated])
|
||||
public async getProjectByID(req: Request, res: Response) {
|
||||
const { id: projectId } = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
const { userId } = res.locals.auth as IJwt;
|
||||
|
||||
const project = await ProjectService.id(projectId);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const isMember = await MembershipService.isMember(projectId, userId);
|
||||
|
||||
if (!isMember) {
|
||||
throw new NotAllowed();
|
||||
}
|
||||
|
||||
return res.json(project);
|
||||
}
|
||||
|
||||
@Get("id/:id/memberships")
|
||||
@Middleware([isAuthenticated])
|
||||
public async getProjectMembershipsByID(req: Request, res: Response) {
|
||||
const { id: projectId } = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
const { userId } = res.locals.auth as IJwt;
|
||||
|
||||
const project = await ProjectService.id(projectId);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const isMember = await MembershipService.isMember(projectId, userId);
|
||||
|
||||
if (!isMember) {
|
||||
throw new NotAllowed();
|
||||
}
|
||||
|
||||
const memberships = await ProjectService.memberships(projectId);
|
||||
|
||||
return res.status(200).json(memberships);
|
||||
}
|
||||
|
||||
@Get("id/:id/usage")
|
||||
@Middleware([isAuthenticated])
|
||||
public async getProjectUsageByID(req: Request, res: Response) {
|
||||
const { id: projectId } = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
const { userId } = res.locals.auth as IJwt;
|
||||
|
||||
const project = await ProjectService.id(projectId);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const isMember = await MembershipService.isMember(projectId, userId);
|
||||
|
||||
if (!isMember) {
|
||||
throw new NotAllowed();
|
||||
}
|
||||
|
||||
const usage = await ProjectService.usage(projectId);
|
||||
|
||||
return res.status(200).json(usage);
|
||||
}
|
||||
|
||||
@Get("id/:id/events")
|
||||
@Middleware([isAuthenticated])
|
||||
public async getProjectEventsByID(req: Request, res: Response) {
|
||||
const { id: projectId } = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
const { triggers } = z
|
||||
.object({
|
||||
triggers: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.default(true)
|
||||
.or(z.string().transform((str) => str.toLowerCase() === "true")),
|
||||
})
|
||||
.parse(req.query);
|
||||
|
||||
const { userId } = res.locals.auth as IJwt;
|
||||
|
||||
const project = await ProjectService.id(projectId);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const isMember = await MembershipService.isMember(projectId, userId);
|
||||
|
||||
if (!isMember) {
|
||||
throw new NotAllowed();
|
||||
}
|
||||
|
||||
const events = await ProjectService.events(projectId, triggers);
|
||||
|
||||
return res.status(200).json(events);
|
||||
}
|
||||
|
||||
@Get("id/:id/actions")
|
||||
@Middleware([isAuthenticated])
|
||||
public async getProjectActionsByID(req: Request, res: Response) {
|
||||
const { id: projectId } = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
const { userId } = res.locals.auth as IJwt;
|
||||
|
||||
const project = await ProjectService.id(projectId);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const isMember = await MembershipService.isMember(projectId, userId);
|
||||
|
||||
if (!isMember) {
|
||||
throw new NotAllowed();
|
||||
}
|
||||
|
||||
const actions = await ProjectService.actions(projectId);
|
||||
|
||||
return res.status(200).json(actions);
|
||||
}
|
||||
|
||||
@Get("id/:id/templates")
|
||||
@Middleware([isAuthenticated])
|
||||
public async getProjectTemplatesByID(req: Request, res: Response) {
|
||||
const { id: projectId } = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
const { userId } = res.locals.auth as IJwt;
|
||||
|
||||
const project = await ProjectService.id(projectId);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const isMember = await MembershipService.isMember(projectId, userId);
|
||||
|
||||
if (!isMember) {
|
||||
throw new NotAllowed();
|
||||
}
|
||||
|
||||
const templates = await ProjectService.templates(projectId);
|
||||
|
||||
return res.status(200).json(templates);
|
||||
}
|
||||
|
||||
@Get("id/:id/contacts/search")
|
||||
@Middleware([isAuthenticated])
|
||||
public async searchContacts(req: Request, res: Response) {
|
||||
const { id: projectId } = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
const { userId } = res.locals.auth as IJwt;
|
||||
|
||||
const project = await ProjectService.id(projectId);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const isMember = await MembershipService.isMember(projectId, userId);
|
||||
|
||||
if (!isMember) {
|
||||
throw new NotAllowed();
|
||||
}
|
||||
|
||||
const { query } = z.object({ query: z.string().min(1) }).parse(req.query);
|
||||
|
||||
const contacts = await prisma.contact.findMany({
|
||||
where: {
|
||||
projectId: project.id,
|
||||
OR: [
|
||||
{ email: { contains: query, mode: "insensitive" } },
|
||||
{ data: { contains: query, mode: "insensitive" } },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
subscribed: true,
|
||||
createdAt: true,
|
||||
triggers: { select: { createdAt: true } },
|
||||
emails: { select: { createdAt: true } },
|
||||
},
|
||||
orderBy: [{ createdAt: "desc" }],
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
contacts,
|
||||
count: contacts.length,
|
||||
});
|
||||
}
|
||||
|
||||
@Get("id/:id/contacts/count")
|
||||
@Middleware([isAuthenticated])
|
||||
public async getProjectContactCountByID(req: Request, res: Response) {
|
||||
const { id: projectId } = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
const { userId } = res.locals.auth as IJwt;
|
||||
|
||||
const project = await ProjectService.id(projectId);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const isMember = await MembershipService.isMember(projectId, userId);
|
||||
|
||||
if (!isMember) {
|
||||
throw new NotAllowed();
|
||||
}
|
||||
|
||||
const count = await ProjectService.contacts.count(projectId);
|
||||
|
||||
return res.status(200).json(count);
|
||||
}
|
||||
|
||||
@Get("id/:id/contacts/metadata")
|
||||
@Middleware([isAuthenticated])
|
||||
public async getProjectMetadataByID(req: Request, res: Response) {
|
||||
const { id: projectId } = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
const { userId } = res.locals.auth as IJwt;
|
||||
|
||||
const project = await ProjectService.id(projectId);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const isMember = await MembershipService.isMember(projectId, userId);
|
||||
|
||||
if (!isMember) {
|
||||
throw new NotAllowed();
|
||||
}
|
||||
|
||||
const metadata = await ProjectService.metadata(projectId);
|
||||
|
||||
return res.status(200).json(metadata);
|
||||
}
|
||||
|
||||
@Get("id/:id/contacts")
|
||||
@Middleware([isAuthenticated])
|
||||
public async getProjectContactsByID(req: Request, res: Response) {
|
||||
const { id: projectId } = UtilitySchemas.id.parse(req.params);
|
||||
const { page } = UtilitySchemas.pagination.parse(req.query);
|
||||
|
||||
const { userId } = res.locals.auth as IJwt;
|
||||
|
||||
const project = await ProjectService.id(projectId);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const isMember = await MembershipService.isMember(projectId, userId);
|
||||
|
||||
if (!isMember) {
|
||||
throw new NotAllowed();
|
||||
}
|
||||
|
||||
if (page === 0) {
|
||||
const contacts = await ProjectService.contacts.get(projectId);
|
||||
|
||||
return res.status(200).json({ contacts, count: contacts?.length });
|
||||
}
|
||||
const contacts = await ProjectService.contacts.paginated(projectId, page);
|
||||
const count = await ProjectService.contacts.count(projectId);
|
||||
|
||||
return res.status(200).json({ contacts, count });
|
||||
}
|
||||
|
||||
@Get("id/:id/feed")
|
||||
@Middleware([isAuthenticated])
|
||||
public async getProjectFeedByID(req: Request, res: Response) {
|
||||
const { id: projectId } = UtilitySchemas.id.parse(req.params);
|
||||
const { page } = UtilitySchemas.pagination.parse(req.query);
|
||||
|
||||
const { userId } = res.locals.auth as IJwt;
|
||||
|
||||
const project = await ProjectService.id(projectId);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const isMember = await MembershipService.isMember(projectId, userId);
|
||||
|
||||
if (!isMember) {
|
||||
throw new NotAllowed();
|
||||
}
|
||||
|
||||
const feed = await ProjectService.feed(projectId, page);
|
||||
|
||||
return res.status(200).json(feed);
|
||||
}
|
||||
|
||||
@Get("id/:id/campaigns")
|
||||
@Middleware([isAuthenticated])
|
||||
public async getProjectCampaignsByID(req: Request, res: Response) {
|
||||
const { id: projectId } = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
const { userId } = res.locals.auth as IJwt;
|
||||
|
||||
const project = await ProjectService.id(projectId);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const isMember = await MembershipService.isMember(projectId, userId);
|
||||
|
||||
if (!isMember) {
|
||||
throw new NotAllowed();
|
||||
}
|
||||
|
||||
const campaigns = await ProjectService.campaigns(projectId);
|
||||
|
||||
return res.status(200).json(campaigns);
|
||||
}
|
||||
|
||||
@Get("id/:id/emails/count")
|
||||
@Middleware([isAuthenticated])
|
||||
public async getProjectEmailCountByID(req: Request, res: Response) {
|
||||
const { id: projectId } = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
const { userId } = res.locals.auth as IJwt;
|
||||
|
||||
const project = await ProjectService.id(projectId);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const isMember = await MembershipService.isMember(projectId, userId);
|
||||
|
||||
if (!isMember) {
|
||||
throw new NotAllowed();
|
||||
}
|
||||
|
||||
const count = await ProjectService.emails.count(projectId);
|
||||
|
||||
return res.status(200).json(count);
|
||||
}
|
||||
|
||||
@Get("id/:id/emails")
|
||||
@Middleware([isAuthenticated])
|
||||
public async getProjectEmailsByID(req: Request, res: Response) {
|
||||
const { id: projectId } = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
const { userId } = res.locals.auth as IJwt;
|
||||
|
||||
const project = await ProjectService.id(projectId);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const isMember = await MembershipService.isMember(projectId, userId);
|
||||
|
||||
if (!isMember) {
|
||||
throw new NotAllowed();
|
||||
}
|
||||
|
||||
const emails = await ProjectService.emails.get(projectId);
|
||||
|
||||
return res.status(200).json(emails);
|
||||
}
|
||||
|
||||
@Get("id/:id/analytics")
|
||||
@Middleware([isAuthenticated])
|
||||
public async getProjectAnalyticsByID(req: Request, res: Response) {
|
||||
const { id: projectId } = UtilitySchemas.id.parse(req.params);
|
||||
const { method } = ProjectSchemas.analytics.parse(req.query);
|
||||
|
||||
const { userId } = res.locals.auth as IJwt;
|
||||
|
||||
const project = await ProjectService.id(projectId);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const isMember = await MembershipService.isMember(projectId, userId);
|
||||
|
||||
if (!isMember) {
|
||||
throw new NotAllowed();
|
||||
}
|
||||
|
||||
const analytics = await ProjectService.analytics({ id: projectId, method });
|
||||
|
||||
return res.status(200).json(analytics);
|
||||
}
|
||||
|
||||
@Post("create")
|
||||
@Middleware([isAuthenticated])
|
||||
public async createProject(req: Request, res: Response) {
|
||||
const { name, url } = ProjectSchemas.create.parse(req.body);
|
||||
|
||||
const { userId } = res.locals.auth as IJwt;
|
||||
|
||||
const user = await UserService.id(userId);
|
||||
|
||||
if (!user) {
|
||||
throw new NotAuthenticated();
|
||||
}
|
||||
|
||||
let secretKey = "";
|
||||
let secretIsAvailable = false;
|
||||
|
||||
let publicKey = "";
|
||||
let publicIsAvailable = false;
|
||||
|
||||
while (!secretIsAvailable) {
|
||||
secretKey = generateToken("secret");
|
||||
|
||||
secretIsAvailable = await ProjectService.secretIsAvailable(secretKey);
|
||||
}
|
||||
|
||||
while (!publicIsAvailable) {
|
||||
publicKey = generateToken("public");
|
||||
|
||||
publicIsAvailable = await ProjectService.publicIsAvailable(publicKey);
|
||||
}
|
||||
|
||||
const project = await prisma.project.create({
|
||||
data: {
|
||||
name,
|
||||
url,
|
||||
secret: secretKey,
|
||||
public: publicKey,
|
||||
memberships: {
|
||||
create: [{ userId, role: "OWNER" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await redis.del(Keys.User.projects(userId));
|
||||
await redis.del(Keys.Project.secret(project.secret));
|
||||
await redis.del(Keys.Project.public(project.public));
|
||||
await redis.del(Keys.Project.id(project.id));
|
||||
|
||||
return res.status(200).json({ success: true, data: project });
|
||||
}
|
||||
|
||||
@Post("id/:id/regenerate")
|
||||
@Middleware([isAuthenticated])
|
||||
public async regenerateAPIkey(req: Request, res: Response) {
|
||||
const { userId } = res.locals.auth as IJwt;
|
||||
|
||||
let project = await ProjectService.id(req.params.id);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const user = await UserService.id(userId);
|
||||
|
||||
if (!user) {
|
||||
throw new NotAuthenticated();
|
||||
}
|
||||
|
||||
const isAdmin = await MembershipService.isAdmin(project.id, userId);
|
||||
|
||||
if (!isAdmin) {
|
||||
throw new NotAllowed();
|
||||
}
|
||||
|
||||
let secretKey = "";
|
||||
let secretIsAvailable = false;
|
||||
|
||||
let publicKey = "";
|
||||
let publicIsAvailable = false;
|
||||
|
||||
while (!secretIsAvailable) {
|
||||
secretKey = generateToken("secret");
|
||||
|
||||
secretIsAvailable = await ProjectService.secretIsAvailable(secretKey);
|
||||
}
|
||||
|
||||
while (!publicIsAvailable) {
|
||||
publicKey = generateToken("public");
|
||||
|
||||
publicIsAvailable = await ProjectService.secretIsAvailable(publicKey);
|
||||
}
|
||||
|
||||
project = await prisma.project.update({
|
||||
where: { id: project.id },
|
||||
data: { secret: secretKey, public: publicKey },
|
||||
});
|
||||
|
||||
await redis.del(Keys.User.projects(userId));
|
||||
|
||||
return res.status(200).json({ success: true, project });
|
||||
}
|
||||
|
||||
@Put("update")
|
||||
@Middleware([isAuthenticated])
|
||||
public async updateProject(req: Request, res: Response) {
|
||||
const { id: projectId, name, url } = ProjectSchemas.update.parse(req.body);
|
||||
|
||||
const { userId } = res.locals.auth as IJwt;
|
||||
|
||||
let project = await ProjectService.id(projectId);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const isAdmin = await MembershipService.isAdmin(projectId, userId);
|
||||
|
||||
if (!isAdmin) {
|
||||
throw new NotAllowed();
|
||||
}
|
||||
|
||||
project = await prisma.project.update({
|
||||
where: { id: projectId },
|
||||
data: {
|
||||
name,
|
||||
url,
|
||||
},
|
||||
});
|
||||
|
||||
await redis.del(Keys.Project.id(project.id));
|
||||
await redis.del(Keys.Project.secret(project.secret));
|
||||
await redis.del(Keys.User.projects(userId));
|
||||
|
||||
return res.status(200).json({ success: true, data: project });
|
||||
}
|
||||
|
||||
@Put("update/identity")
|
||||
@Middleware([isAuthenticated])
|
||||
public async updateIdentity(req: Request, res: Response) {
|
||||
const { id: projectId, from } = IdentitySchemas.update.parse(req.body);
|
||||
|
||||
const { userId } = res.locals.auth as IJwt;
|
||||
|
||||
let project = await ProjectService.id(projectId);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const isAdmin = await MembershipService.isAdmin(projectId, userId);
|
||||
|
||||
if (!isAdmin) {
|
||||
throw new NotAllowed();
|
||||
}
|
||||
|
||||
project = await prisma.project.update({
|
||||
where: { id: projectId },
|
||||
data: {
|
||||
from,
|
||||
},
|
||||
});
|
||||
|
||||
await redis.del(Keys.Project.id(project.id));
|
||||
await redis.del(Keys.Project.secret(project.secret));
|
||||
await redis.del(Keys.User.projects(userId));
|
||||
|
||||
return res.status(200).json({ success: true, data: project });
|
||||
}
|
||||
|
||||
@Delete("delete")
|
||||
@Middleware([isAuthenticated])
|
||||
public async deleteProject(req: Request, res: Response) {
|
||||
const { id: projectId } = UtilitySchemas.id.parse(req.body);
|
||||
|
||||
const { userId } = res.locals.auth as IJwt;
|
||||
|
||||
const project = await ProjectService.id(projectId);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const user = await UserService.id(userId);
|
||||
|
||||
if (!user) {
|
||||
throw new NotAuthenticated();
|
||||
}
|
||||
|
||||
const isOwner = await MembershipService.isOwner(projectId, userId);
|
||||
|
||||
if (!isOwner) {
|
||||
throw new NotAllowed();
|
||||
}
|
||||
|
||||
await prisma.project.delete({ where: { id: project.id } });
|
||||
|
||||
await redis.del(Keys.User.projects(userId));
|
||||
await redis.del(Keys.Project.id(projectId));
|
||||
|
||||
return res.status(200).json({ success: true, data: project });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { Controller, Post } from "@overnightjs/core";
|
||||
import type { Request, Response } from "express";
|
||||
import signale from "signale";
|
||||
import { prisma } from "../database/prisma";
|
||||
import { ContactService } from "../services/ContactService";
|
||||
import { EmailService } from "../services/EmailService";
|
||||
import { ProjectService } from "../services/ProjectService";
|
||||
|
||||
@Controller("tasks")
|
||||
export class Tasks {
|
||||
@Post()
|
||||
public async handleTasks(req: Request, res: Response) {
|
||||
// Get all tasks with a runBy data in the past
|
||||
const tasks = await prisma.task.findMany({
|
||||
where: { runBy: { lte: new Date() } },
|
||||
orderBy: { runBy: "asc" },
|
||||
include: {
|
||||
action: { include: { template: true, notevents: true } },
|
||||
campaign: true,
|
||||
contact: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (const task of tasks) {
|
||||
const { action, campaign, contact } = task;
|
||||
|
||||
const project = await ProjectService.id(contact.projectId);
|
||||
|
||||
// If the project does not exist or is disabled, delete all tasks
|
||||
if (!project) {
|
||||
await prisma.task.deleteMany({
|
||||
where: {
|
||||
contact: {
|
||||
projectId: contact.projectId,
|
||||
},
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let subject = "";
|
||||
let body = "";
|
||||
|
||||
if (action) {
|
||||
const { template, notevents } = action;
|
||||
|
||||
if (notevents.length > 0) {
|
||||
const triggers = await ContactService.triggers(contact.id);
|
||||
if (
|
||||
notevents.some((e) =>
|
||||
triggers.some(
|
||||
(t) => t.contactId === contact.id && t.eventId === e.id,
|
||||
),
|
||||
)
|
||||
) {
|
||||
await prisma.task.delete({ where: { id: task.id } });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
({ subject, body } = EmailService.format({
|
||||
subject: template.subject,
|
||||
body: template.body,
|
||||
data: {
|
||||
plunk_id: contact.id,
|
||||
plunk_email: contact.email,
|
||||
...JSON.parse(contact.data ?? "{}"),
|
||||
},
|
||||
}));
|
||||
} else if (campaign) {
|
||||
({ subject, body } = EmailService.format({
|
||||
subject: campaign.subject,
|
||||
body: campaign.body,
|
||||
data: {
|
||||
plunk_id: contact.id,
|
||||
plunk_email: contact.email,
|
||||
...JSON.parse(contact.data ?? "{}"),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
const { messageId } = await EmailService.send({
|
||||
from: {
|
||||
name: project.from ?? project.name,
|
||||
email:
|
||||
project.verified && project.email
|
||||
? project.email
|
||||
: "[email protected]",
|
||||
},
|
||||
to: [contact.email],
|
||||
content: {
|
||||
subject,
|
||||
html: EmailService.compile({
|
||||
content: body,
|
||||
footer: {
|
||||
unsubscribe: campaign
|
||||
? true
|
||||
: !!action && action.template.type === "MARKETING",
|
||||
},
|
||||
contact: {
|
||||
id: contact.id,
|
||||
},
|
||||
project: {
|
||||
name: project.name,
|
||||
},
|
||||
isHtml:
|
||||
(campaign && campaign.style === "HTML") ??
|
||||
(!!action && action.template.style === "HTML"),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const emailData: {
|
||||
messageId: string;
|
||||
contactId: string;
|
||||
actionId?: string;
|
||||
campaignId?: string;
|
||||
} = {
|
||||
messageId,
|
||||
contactId: contact.id,
|
||||
};
|
||||
|
||||
if (action) {
|
||||
emailData.actionId = action.id;
|
||||
} else if (campaign) {
|
||||
emailData.campaignId = campaign.id;
|
||||
}
|
||||
|
||||
await prisma.email.create({ data: emailData });
|
||||
|
||||
await prisma.task.delete({ where: { id: task.id } });
|
||||
|
||||
signale.success(
|
||||
`Task completed for ${contact.email} from ${project.name}`,
|
||||
);
|
||||
}
|
||||
|
||||
return res.status(200).json({ success: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Controller, Get, Middleware } from "@overnightjs/core";
|
||||
import type { Request, Response } from "express";
|
||||
import { NotAuthenticated } from "../exceptions";
|
||||
import { type IJwt, isAuthenticated } from "../middleware/auth";
|
||||
import { UserService } from "../services/UserService";
|
||||
|
||||
@Controller("users")
|
||||
export class Users {
|
||||
@Get("@me")
|
||||
@Middleware([isAuthenticated])
|
||||
public async me(req: Request, res: Response) {
|
||||
const auth = res.locals.auth as IJwt;
|
||||
|
||||
const me = await UserService.id(auth.userId);
|
||||
|
||||
if (!me) {
|
||||
throw new NotAuthenticated();
|
||||
}
|
||||
|
||||
return res.status(200).json({ id: me.id, email: me.email });
|
||||
}
|
||||
|
||||
@Get("@me/projects")
|
||||
@Middleware([isAuthenticated])
|
||||
public async meProjects(req: Request, res: Response) {
|
||||
const auth = res.locals.auth as IJwt;
|
||||
|
||||
const projects = await UserService.projects(auth.userId);
|
||||
|
||||
return res.status(200).json(projects);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { Controller, Post } from "@overnightjs/core";
|
||||
import type { Event } from "@prisma/client";
|
||||
import type { Request, Response } from "express";
|
||||
import signale from "signale";
|
||||
import { prisma } from "../../../database/prisma";
|
||||
import { ActionService } from "../../../services/ActionService";
|
||||
import { ProjectService } from "../../../services/ProjectService";
|
||||
|
||||
const eventMap = {
|
||||
Bounce: "BOUNCED",
|
||||
Delivery: "DELIVERED",
|
||||
Open: "OPENED",
|
||||
Complaint: "COMPLAINT",
|
||||
Click: "CLICKED",
|
||||
} as const;
|
||||
|
||||
@Controller("sns")
|
||||
export class SNSWebhook {
|
||||
@Post()
|
||||
public async receiveSNSWebhook(req: Request, res: Response) {
|
||||
try {
|
||||
const body = JSON.parse(req.body.Message);
|
||||
|
||||
const email = await prisma.email.findUnique({
|
||||
where: { messageId: body.mail.messageId },
|
||||
include: {
|
||||
contact: true,
|
||||
action: { include: { template: { include: { events: true } } } },
|
||||
campaign: { include: { events: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!email) {
|
||||
return res.status(200).json({});
|
||||
}
|
||||
|
||||
const project = await ProjectService.id(email.contact.projectId);
|
||||
|
||||
if (!project) {
|
||||
return res.status(200).json({ success: false });
|
||||
}
|
||||
|
||||
// The email was a transactional email
|
||||
if (email.projectId) {
|
||||
if (body.eventType === "Click") {
|
||||
signale.success(
|
||||
`Click received for ${email.contact.email} from ${project.name}`,
|
||||
);
|
||||
await prisma.click.create({
|
||||
data: { emailId: email.id, link: body.click.link },
|
||||
});
|
||||
}
|
||||
|
||||
if (body.eventType === "Complaint") {
|
||||
signale.warn(
|
||||
`Complaint received for ${email.contact.email} from ${project.name}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (body.eventType === "Bounce") {
|
||||
signale.warn(
|
||||
`Bounce received for ${email.contact.email} from ${project.name}`,
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.email.update({
|
||||
where: { messageId: body.mail.messageId },
|
||||
data: {
|
||||
status:
|
||||
eventMap[
|
||||
body.eventType as "Bounce" | "Delivery" | "Open" | "Complaint"
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
return res.status(200).json({ success: true });
|
||||
}
|
||||
|
||||
if (body.eventType === "Complaint" || body.eventType === "Bounce") {
|
||||
signale.warn(
|
||||
`${body.eventType === "Complaint" ? "Complaint" : "Bounce"} received for ${email.contact.email} from ${project.name}`,
|
||||
);
|
||||
|
||||
await prisma.email.update({
|
||||
where: { messageId: body.mail.messageId },
|
||||
data: { status: eventMap[body.eventType as "Bounce" | "Complaint"] },
|
||||
});
|
||||
|
||||
await prisma.contact.update({
|
||||
where: { id: email.contactId },
|
||||
data: { subscribed: false },
|
||||
});
|
||||
|
||||
return res.status(200).json({ success: true });
|
||||
}
|
||||
|
||||
if (body.eventType === "Click") {
|
||||
signale.success(
|
||||
`Click received for ${email.contact.email} from ${project.name}`,
|
||||
);
|
||||
|
||||
await prisma.click.create({
|
||||
data: { emailId: email.id, link: body.click.link },
|
||||
});
|
||||
|
||||
return res.status(200).json({ success: true });
|
||||
}
|
||||
|
||||
let event: Event | undefined;
|
||||
|
||||
if (email.action) {
|
||||
event = email.action.template.events.find((e) =>
|
||||
e.name.includes(
|
||||
(body.eventType as
|
||||
| "Bounce"
|
||||
| "Delivery"
|
||||
| "Open"
|
||||
| "Complaint"
|
||||
| "Click") === "Delivery"
|
||||
? "delivered"
|
||||
: "opened",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (email.campaign) {
|
||||
event = email.campaign.events.find((e) =>
|
||||
e.name.includes(
|
||||
(body.eventType as
|
||||
| "Bounce"
|
||||
| "Delivery"
|
||||
| "Open"
|
||||
| "Complaint"
|
||||
| "Click") === "Delivery"
|
||||
? "delivered"
|
||||
: "opened",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (!event) {
|
||||
return res.status(200).json({ success: false });
|
||||
}
|
||||
|
||||
switch (body.eventType as "Delivery" | "Open") {
|
||||
case "Delivery":
|
||||
signale.success(
|
||||
`Delivery received for ${email.contact.email} from ${project.name}`,
|
||||
);
|
||||
await prisma.email.update({
|
||||
where: { messageId: body.mail.messageId },
|
||||
data: { status: "DELIVERED" },
|
||||
});
|
||||
|
||||
await prisma.trigger.create({
|
||||
data: { contactId: email.contactId, eventId: event.id },
|
||||
});
|
||||
|
||||
break;
|
||||
case "Open":
|
||||
signale.success(
|
||||
`Open received for ${email.contact.email} from ${project.name}`,
|
||||
);
|
||||
await prisma.email.update({
|
||||
where: { messageId: body.mail.messageId },
|
||||
data: { status: "OPENED" },
|
||||
});
|
||||
await prisma.trigger.create({
|
||||
data: { contactId: email.contactId, eventId: event.id },
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
if (email.action) {
|
||||
void ActionService.trigger({ event, contact: email.contact, project });
|
||||
}
|
||||
} catch (e) {
|
||||
if (req.body.SubscribeURL) {
|
||||
signale.info("--------------");
|
||||
signale.info("SNS Topic Confirmation URL:");
|
||||
signale.info(req.body.SubscribeURL);
|
||||
signale.info("--------------");
|
||||
} else {
|
||||
signale.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(200).json({ success: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import {ChildControllers, Controller} from '@overnightjs/core';
|
||||
import {SNSWebhook} from './SNS';
|
||||
|
||||
@Controller('incoming')
|
||||
@ChildControllers([new SNSWebhook()])
|
||||
export class IncomingWebhooks {}
|
||||
@@ -0,0 +1,6 @@
|
||||
import {ChildControllers, Controller} from '@overnightjs/core';
|
||||
import {IncomingWebhooks} from './Incoming';
|
||||
|
||||
@Controller('webhooks')
|
||||
@ChildControllers([new IncomingWebhooks()])
|
||||
export class Webhooks {}
|
||||
@@ -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