Initial Commit

This commit is contained in:
Dries Augustyns
2024-07-23 13:49:48 +02:00
commit 91a8840c3c
191 changed files with 36022 additions and 0 deletions
+125
View File
@@ -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);
}
}
+171
View File
@@ -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 });
}
}
+147
View File
@@ -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 });
}
}
+624
View File
@@ -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 });
}
}
+140
View File
@@ -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 });
}
}
+32
View File
@@ -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 {}
+270
View File
@@ -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);
}
}
+334
View File
@@ -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,
});
}
}
+39
View File
@@ -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);
}
}
+298
View File
@@ -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() });
}
}