Initial Commit
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
import "dotenv/config";
|
||||
import "express-async-errors";
|
||||
|
||||
import { STATUS_CODES } from "node:http";
|
||||
import { Server } from "@overnightjs/core";
|
||||
import compression from "compression";
|
||||
import cookies from "cookie-parser";
|
||||
import cors from "cors";
|
||||
import { type NextFunction, type Request, type Response, json } from "express";
|
||||
import helmet from "helmet";
|
||||
import morgan from "morgan";
|
||||
import signale from "signale";
|
||||
import { API_URI, NODE_ENV, PORT } from "./app/constants";
|
||||
import { task } from "./app/cron";
|
||||
import { Auth } from "./controllers/Auth";
|
||||
import { Identities } from "./controllers/Identities";
|
||||
import { Memberships } from "./controllers/Memberships";
|
||||
import { Projects } from "./controllers/Projects";
|
||||
import { Tasks } from "./controllers/Tasks";
|
||||
import { Users } from "./controllers/Users";
|
||||
import { Webhooks } from "./controllers/Webhooks";
|
||||
import { V1 } from "./controllers/v1";
|
||||
import { prisma } from "./database/prisma";
|
||||
import { HttpException } from "./exceptions";
|
||||
|
||||
const server = new (class extends Server {
|
||||
public constructor() {
|
||||
super();
|
||||
|
||||
// Set the content-type to JSON for any request coming from AWS SNS
|
||||
this.app.use((req, res, next) => {
|
||||
if (req.get("x-amz-sns-message-type")) {
|
||||
req.headers["content-type"] = "application/json";
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
this.app.use(
|
||||
compression({
|
||||
threshold: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
// Parse the rest of our application as json
|
||||
this.app.use(json({ limit: "50mb" }));
|
||||
this.app.use(cookies());
|
||||
this.app.use(helmet());
|
||||
|
||||
this.app.use(["/v1", "/v1/track", "/v1/send"], (req, res, next) => {
|
||||
res.set({ "Access-Control-Allow-Origin": "*" });
|
||||
next();
|
||||
});
|
||||
|
||||
this.app.use(
|
||||
cors({
|
||||
origin: [API_URI],
|
||||
credentials: true,
|
||||
}),
|
||||
);
|
||||
|
||||
this.app.use(morgan(NODE_ENV === "development" ? "dev" : "short"));
|
||||
|
||||
this.addControllers([
|
||||
new Auth(),
|
||||
new Users(),
|
||||
new Projects(),
|
||||
new Memberships(),
|
||||
new Webhooks(),
|
||||
new Identities(),
|
||||
new Tasks(),
|
||||
new V1(),
|
||||
]);
|
||||
|
||||
this.app.use("*", () => {
|
||||
throw new HttpException(404, "Unknown route");
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
server.app.use((req, res, next) => {
|
||||
console.log(`Incoming request: ${req.method} ${req.path}`);
|
||||
next();
|
||||
});
|
||||
|
||||
server.app.use(
|
||||
(error: Error, req: Request, res: Response, _next: NextFunction) => {
|
||||
const code = error instanceof HttpException ? error.code : 500;
|
||||
|
||||
if (NODE_ENV !== "development") {
|
||||
signale.error(error);
|
||||
}
|
||||
|
||||
res.status(code).json({
|
||||
code,
|
||||
error: STATUS_CODES[code],
|
||||
message: error.message,
|
||||
time: Date.now(),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
void prisma.$connect().then(() => {
|
||||
server.app.listen(PORT, () => {
|
||||
task.start();
|
||||
|
||||
signale.success("[HTTPS] Ready on", PORT);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Safely parse environment variables
|
||||
* @param key The key
|
||||
* @param defaultValue An optional default value if the environment variable does not exist
|
||||
*/
|
||||
export function validateEnv<T extends string = string>(
|
||||
key: keyof NodeJS.ProcessEnv,
|
||||
defaultValue?: T,
|
||||
): T {
|
||||
const value = process.env[key] as T | undefined;
|
||||
|
||||
if (!value) {
|
||||
if (typeof defaultValue !== "undefined") {
|
||||
return defaultValue;
|
||||
}
|
||||
throw new Error(`${key} is not defined in environment variables`);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
// ENV
|
||||
export const JWT_SECRET = validateEnv("JWT_SECRET");
|
||||
export const PORT = validateEnv<`${number}`>("PORT", "4000");
|
||||
export const NODE_ENV = validateEnv<"development" | "production">(
|
||||
"NODE_ENV",
|
||||
"production",
|
||||
);
|
||||
|
||||
export const REDIS_URL = validateEnv("REDIS_URL");
|
||||
|
||||
// URLs
|
||||
export const API_URI = validateEnv("API_URI", "http://localhost:8080");
|
||||
export const APP_URI = validateEnv("APP_URI", "http://localhost:3000");
|
||||
|
||||
// AWS
|
||||
export const AWS_REGION = validateEnv("AWS_REGION");
|
||||
export const AWS_ACCESS_KEY_ID = validateEnv("AWS_SES_ACCESS_KEY_ID");
|
||||
export const AWS_SECRET_ACCESS_KEY = validateEnv("AWS_SES_SECRET_ACCESS_KEY");
|
||||
export const AWS_SES_CONFIGURATION_SET = validateEnv(
|
||||
"AWS_SES_CONFIGURATION_SET",
|
||||
);
|
||||
@@ -0,0 +1,15 @@
|
||||
import cron from 'node-cron';
|
||||
import {API_URI} from './constants';
|
||||
import signale from 'signale';
|
||||
|
||||
export const task = cron.schedule('* * * * *', () => {
|
||||
signale.info('Running scheduled tasks');
|
||||
void fetch(`${API_URI}/tasks`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
signale.info('Updating verified identities');
|
||||
void fetch(`${API_URI}/identities/update`, {
|
||||
method: 'POST',
|
||||
});
|
||||
});
|
||||
@@ -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() });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import {PrismaClient} from '@prisma/client';
|
||||
|
||||
export const prisma = new PrismaClient();
|
||||
@@ -0,0 +1,34 @@
|
||||
export class HttpException extends Error {
|
||||
public constructor(
|
||||
public readonly code: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFound extends HttpException {
|
||||
/**
|
||||
* Construct a new NotFound exception
|
||||
* @param resource The type of resource that was not found
|
||||
*/
|
||||
public constructor(resource: string) {
|
||||
super(404, `That ${resource.toLowerCase()} was not found`);
|
||||
}
|
||||
}
|
||||
|
||||
export class NotAllowed extends HttpException {
|
||||
/**
|
||||
* Construct a new NotAllowed exception
|
||||
* @param msg
|
||||
*/
|
||||
public constructor(msg = 'You are not allowed to perform this action') {
|
||||
super(403, msg);
|
||||
}
|
||||
}
|
||||
|
||||
export class NotAuthenticated extends HttpException {
|
||||
public constructor() {
|
||||
super(401, 'You need to be authenticated to do this');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import dayjs from "dayjs";
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import jsonwebtoken from "jsonwebtoken";
|
||||
import { JWT_SECRET } from "../app/constants";
|
||||
import { HttpException, NotAuthenticated } from "../exceptions";
|
||||
|
||||
export interface IJwt {
|
||||
type: "jwt";
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export interface ISecret {
|
||||
type: "secret";
|
||||
sk: string;
|
||||
}
|
||||
|
||||
export interface IKey {
|
||||
type: "key";
|
||||
key: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to check if this unsubscribe is authenticated on the dashboard
|
||||
* @param req
|
||||
* @param res
|
||||
* @param next
|
||||
*/
|
||||
export const isAuthenticated = (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) => {
|
||||
res.locals.auth = { type: "jwt", userId: parseJwt(req) };
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
/**
|
||||
* Middleware to check if this request is signed with an API secret key
|
||||
* @param req
|
||||
* @param res
|
||||
* @param next
|
||||
*/
|
||||
export const isValidSecretKey = (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) => {
|
||||
res.locals.auth = { type: "secret", sk: parseBearer(req, "secret") };
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
export const isValidKey = (req: Request, res: Response, next: NextFunction) => {
|
||||
res.locals.auth = { type: "key", key: parseBearer(req) };
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
export const jwt = {
|
||||
/**
|
||||
* Extracts a unsubscribe id from a jwt
|
||||
* @param token The JWT token
|
||||
*/
|
||||
verify(token: string): string | null {
|
||||
try {
|
||||
const verified = jsonwebtoken.verify(token, JWT_SECRET) as {
|
||||
id: string;
|
||||
};
|
||||
return verified.id;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Signs a JWT token
|
||||
* @param id The unsubscribe's ID to sign into a jwt token
|
||||
*/
|
||||
sign(id: string): string {
|
||||
return jsonwebtoken.sign({ id }, JWT_SECRET, {
|
||||
expiresIn: "168h",
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Find out when a JWT expires
|
||||
* @param token The unsubscribe's jwt token
|
||||
*/
|
||||
expires(token: string): dayjs.Dayjs {
|
||||
const { exp } = jsonwebtoken.verify(token, JWT_SECRET) as {
|
||||
exp?: number;
|
||||
};
|
||||
return dayjs(exp);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse a unsubscribe's ID from the request JWT token
|
||||
* @param request The express request object
|
||||
*/
|
||||
export function parseJwt(request: Request): string {
|
||||
const token: string | undefined = request.cookies.token;
|
||||
|
||||
if (!token) {
|
||||
throw new NotAuthenticated();
|
||||
}
|
||||
|
||||
const id = jwt.verify(token);
|
||||
|
||||
if (!id) {
|
||||
throw new NotAuthenticated();
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a bearer token from the request headers
|
||||
* @param request The express request object
|
||||
* @param type
|
||||
*/
|
||||
export function parseBearer(
|
||||
request: Request,
|
||||
type?: "secret" | "public",
|
||||
): string {
|
||||
const bearer: string | undefined = request.headers.authorization;
|
||||
|
||||
if (!bearer) {
|
||||
throw new HttpException(401, "No authorization header passed");
|
||||
}
|
||||
|
||||
if (!bearer.includes("Bearer")) {
|
||||
throw new HttpException(401, "Please add Bearer in front of your API key");
|
||||
}
|
||||
|
||||
const split = bearer.split(" ");
|
||||
|
||||
if (!(split[0] === "Bearer") || split.length > 2) {
|
||||
throw new HttpException(
|
||||
401,
|
||||
"Your authorization header is malformed. Please pass your API key as Bearer sk_...",
|
||||
);
|
||||
}
|
||||
|
||||
if (!type && !split[1].startsWith("sk_") && !split[1].startsWith("pk_")) {
|
||||
throw new HttpException(
|
||||
401,
|
||||
"Your API key could not be parsed. API keys start with sk_ or pk_",
|
||||
);
|
||||
}
|
||||
|
||||
if (!type) {
|
||||
return split[1];
|
||||
}
|
||||
|
||||
if (type === "secret" && split[1].startsWith("pk_")) {
|
||||
throw new HttpException(
|
||||
401,
|
||||
"You attached a public key but this route may only be accessed with a secret key",
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "secret" && !split[1].startsWith("sk_")) {
|
||||
throw new HttpException(
|
||||
401,
|
||||
"Your secret key could not be parsed. Secret keys start with sk_ and should be passed in the authorization header as Bearer sk_...",
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "public" && !split[1].startsWith("pk_")) {
|
||||
throw new HttpException(
|
||||
401,
|
||||
"Your public key could not be parsed. Public keys start with pk_ and should be passed in the authorization header as Bearer sk_...",
|
||||
);
|
||||
}
|
||||
|
||||
return split[1];
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import type { Contact, Event, Project } from "@prisma/client";
|
||||
import dayjs from "dayjs";
|
||||
import { prisma } from "../database/prisma";
|
||||
import { ContactService } from "./ContactService";
|
||||
import { EmailService } from "./EmailService";
|
||||
import { Keys } from "./keys";
|
||||
import { wrapRedis } from "./redis";
|
||||
|
||||
export class ActionService {
|
||||
/**
|
||||
* Gets an action by its ID
|
||||
* @param id
|
||||
*/
|
||||
public static id(id: string) {
|
||||
return wrapRedis(Keys.Action.id(id), async () => {
|
||||
return prisma.action.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
events: true,
|
||||
notevents: true,
|
||||
triggers: true,
|
||||
emails: true,
|
||||
template: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all actions that share an event with the action with the given ID
|
||||
* @param id
|
||||
*/
|
||||
public static related(id: string) {
|
||||
return wrapRedis(Keys.Action.related(id), async () => {
|
||||
const action = await ActionService.id(id);
|
||||
|
||||
if (!action) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return prisma.action.findMany({
|
||||
where: {
|
||||
events: { some: { id: { in: action.events.map((e) => e.id) } } },
|
||||
id: { not: action.id },
|
||||
},
|
||||
include: { events: true },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all actions that have an event as a trigger
|
||||
* @param eventId
|
||||
*/
|
||||
public static event(eventId: string) {
|
||||
return wrapRedis(Keys.Action.event(eventId), async () => {
|
||||
return prisma.event
|
||||
.findUniqueOrThrow({ where: { id: eventId } })
|
||||
.actions({
|
||||
include: { events: true, template: true, notevents: true },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a contact and an event and triggers all required actions
|
||||
* @param contact
|
||||
* @param event
|
||||
* @param project
|
||||
*/
|
||||
public static async trigger({
|
||||
event,
|
||||
contact,
|
||||
project,
|
||||
}: { event: Event; contact: Contact; project: Project }) {
|
||||
const actions = await ActionService.event(event.id);
|
||||
|
||||
const triggers = await ContactService.triggers(contact.id);
|
||||
|
||||
for (const action of actions) {
|
||||
const hasTriggeredAction = !!triggers.find(
|
||||
(t) => t.actionId === action.id,
|
||||
);
|
||||
|
||||
if (action.runOnce && hasTriggeredAction) {
|
||||
// User has already triggered this run once action
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
action.notevents.length > 0 &&
|
||||
action.notevents.some((e) => triggers.some((t) => t.eventId === e.id))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let triggeredEvents = triggers.filter((t) => t.eventId === event.id);
|
||||
|
||||
if (hasTriggeredAction) {
|
||||
const lastActionTrigger = triggers.filter(
|
||||
(t) => t.contactId === contact.id && t.actionId === action.id,
|
||||
)[0];
|
||||
|
||||
triggeredEvents = triggeredEvents.filter(
|
||||
(e) => e.createdAt > lastActionTrigger.createdAt,
|
||||
);
|
||||
}
|
||||
|
||||
const updatedTriggers = [
|
||||
...new Set(triggeredEvents.map((t) => t.eventId)),
|
||||
];
|
||||
const requiredTriggers = action.events.map((e) => e.id);
|
||||
|
||||
if (
|
||||
updatedTriggers.sort().join(",") !== requiredTriggers.sort().join(",")
|
||||
) {
|
||||
// Not all required events have been triggered
|
||||
continue;
|
||||
}
|
||||
|
||||
await prisma.trigger.create({
|
||||
data: { actionId: action.id, contactId: contact.id },
|
||||
});
|
||||
|
||||
if (!contact.subscribed && action.template.type === "MARKETING") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (action.delay === 0) {
|
||||
const { subject, body } = EmailService.format({
|
||||
subject: action.template.subject,
|
||||
body: action.template.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: action.template.type === "MARKETING",
|
||||
},
|
||||
contact: {
|
||||
id: contact.id,
|
||||
},
|
||||
project: {
|
||||
name: project.name,
|
||||
},
|
||||
isHtml: action.template.style === "HTML",
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.email.create({
|
||||
data: {
|
||||
messageId,
|
||||
actionId: action.id,
|
||||
contactId: contact.id,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await prisma.task.create({
|
||||
data: {
|
||||
actionId: action.id,
|
||||
contactId: contact.id,
|
||||
runBy: dayjs().add(action.delay, "minutes").toDate(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import {prisma} from '../database/prisma';
|
||||
import {verifyHash} from '../util/hash';
|
||||
|
||||
export class AuthService {
|
||||
public static async verifyCredentials(email: string, password: string) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
email: email,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user?.password) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return await verifyHash(password, user.password);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import {Keys} from './keys';
|
||||
import {wrapRedis} from './redis';
|
||||
import {prisma} from '../database/prisma';
|
||||
|
||||
export class CampaignService {
|
||||
public static id(id: string) {
|
||||
return wrapRedis(Keys.Campaign.id(id), async () => {
|
||||
return prisma.campaign.findUnique({
|
||||
where: {id},
|
||||
include: {
|
||||
recipients: {select: {id: true}},
|
||||
emails: {select: {id: true, status: true, contact: {select: {id: true, email: true}}}},
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import {Keys} from './keys';
|
||||
import {wrapRedis} from './redis';
|
||||
import {prisma} from '../database/prisma';
|
||||
|
||||
export class ContactService {
|
||||
public static id(id: string) {
|
||||
return wrapRedis(Keys.Contact.id(id), async () => {
|
||||
return prisma.contact.findUnique({
|
||||
where: {id},
|
||||
include: {triggers: {include: {event: true, action: true}}, emails: {where: {subject: {not: null}}}},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public static email(projectId: string, email: string) {
|
||||
return wrapRedis(Keys.Contact.email(projectId, email), () => {
|
||||
return prisma.contact.findFirst({where: {projectId, email}});
|
||||
});
|
||||
}
|
||||
|
||||
public static async triggers(id: string) {
|
||||
return prisma.contact.findUniqueOrThrow({where: {id}}).triggers();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,531 @@
|
||||
import mjml2html from "mjml";
|
||||
import { APP_URI, AWS_SES_CONFIGURATION_SET } from "../app/constants";
|
||||
import { ses } from "../util/ses";
|
||||
|
||||
export class EmailService {
|
||||
public static async send({
|
||||
from,
|
||||
to,
|
||||
content,
|
||||
reply,
|
||||
headers,
|
||||
}: {
|
||||
from: {
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
reply?: string;
|
||||
to: string[];
|
||||
content: {
|
||||
subject: string;
|
||||
html: string;
|
||||
};
|
||||
headers?: {
|
||||
[key: string]: string;
|
||||
} | null;
|
||||
}) {
|
||||
// Check if the body contains an unsubscribe link
|
||||
const regex = /unsubscribe\/([a-f\d-]+)"/;
|
||||
const containsUnsubscribeLink = content.html.match(regex);
|
||||
|
||||
let unsubscribeLink = "";
|
||||
if (containsUnsubscribeLink?.[1]) {
|
||||
const unsubscribeId = containsUnsubscribeLink[1];
|
||||
unsubscribeLink = `List-Unsubscribe: <https://${APP_URI}/unsubscribe/${unsubscribeId}>`;
|
||||
}
|
||||
|
||||
const rawMessage = `From: ${from.name} <${from.email}>
|
||||
To: ${to.join(", ")}
|
||||
Reply-To: ${reply || from.email}
|
||||
Subject: ${content.subject}
|
||||
MIME-Version: 1.0
|
||||
Content-Type: multipart/alternative; boundary="NextPart"
|
||||
${
|
||||
headers
|
||||
? Object.entries(headers)
|
||||
.map(([key, value]) => `${key}: ${value}`)
|
||||
.join("\n")
|
||||
: ""
|
||||
}
|
||||
${unsubscribeLink}
|
||||
|
||||
--NextPart
|
||||
Content-Type: text/html; charset=utf-8
|
||||
Content-Transfer-Encoding: 7bit
|
||||
|
||||
${EmailService.breakLongLines(content.html, 500)}
|
||||
--NextPart--
|
||||
`;
|
||||
|
||||
const response = await ses.sendRawEmail({
|
||||
Destinations: to,
|
||||
ConfigurationSetName: AWS_SES_CONFIGURATION_SET,
|
||||
RawMessage: {
|
||||
Data: new TextEncoder().encode(rawMessage),
|
||||
},
|
||||
Source: `${from.name} <${from.email}>`,
|
||||
});
|
||||
|
||||
if (!response.MessageId) {
|
||||
throw new Error("Could not send email");
|
||||
}
|
||||
|
||||
return { messageId: response.MessageId };
|
||||
}
|
||||
|
||||
public static compile({
|
||||
content,
|
||||
footer,
|
||||
contact,
|
||||
project,
|
||||
isHtml,
|
||||
}: {
|
||||
content: string;
|
||||
|
||||
project: {
|
||||
name: string;
|
||||
};
|
||||
contact: {
|
||||
id: string;
|
||||
};
|
||||
footer: {
|
||||
unsubscribe?: boolean;
|
||||
};
|
||||
isHtml?: boolean;
|
||||
}) {
|
||||
const html = content.replace(/<img/g, "<img");
|
||||
|
||||
if (isHtml) {
|
||||
return `${html}
|
||||
|
||||
${
|
||||
footer.unsubscribe
|
||||
? ` <table align="center" width="100%" style="max-width: 480px; width: 100%; margin-left: auto; margin-right: auto; font-family: Inter, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; border: 0; cellpadding: 0; cellspacing: 0;" role="presentation">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<hr style="border: none; border-top: 1px solid #eaeaea; width: 100%; margin-top: 12px; margin-bottom: 12px;">
|
||||
<p style="font-size: 12px; line-height: 24px; margin: 16px 0; text-align: center; color: rgb(64, 64, 64);">
|
||||
You received this email because you agreed to receive emails from ${project.name}. If you no longer wish to receive emails like this, please
|
||||
<a href="https://${APP_URI}/unsubscribe/${contact.id}">update your preferences</a>.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>`
|
||||
: ""
|
||||
}`;
|
||||
}
|
||||
return mjml2html(
|
||||
`<mjml>
|
||||
<mj-head>
|
||||
<mj-font name="Inter" href="https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900&display=swap" />
|
||||
<mj-style inline="inline">
|
||||
.prose {
|
||||
color: #4a5568;
|
||||
max-width: 600px;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
|
||||
}
|
||||
|
||||
.prose [class~="lead"] {
|
||||
color: #4a5568;
|
||||
font-size: 20px;
|
||||
line-height: 32px;
|
||||
margin-top: 19px;
|
||||
margin-bottom: 19px;
|
||||
}
|
||||
|
||||
.prose a {
|
||||
color: #1a202c;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.prose strong {
|
||||
color: #1a202c;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.prose ol {
|
||||
counter-reset: list-counter;
|
||||
margin-top: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.prose ol > li {
|
||||
position: relative;
|
||||
counter-increment: list-counter;
|
||||
padding-left: 28px;
|
||||
}
|
||||
|
||||
.prose ol > li::before {
|
||||
content: counter(list-counter) ".";
|
||||
position: absolute;
|
||||
font-weight: 400;
|
||||
color: #718096;
|
||||
}
|
||||
|
||||
.prose ul > li {
|
||||
position: relative;
|
||||
padding-left: 28px;
|
||||
}
|
||||
|
||||
.prose ul > li::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
background-color: #cbd5e0;
|
||||
border-radius: 50%;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
top: 11px;
|
||||
left: 4px;
|
||||
}
|
||||
|
||||
.prose hr {
|
||||
border-color: #e2e8f0;
|
||||
border-top-width: 1px;
|
||||
margin-top: 42px;
|
||||
margin-bottom: 42px;
|
||||
}
|
||||
|
||||
.prose blockquote {
|
||||
font-weight: 500;
|
||||
font-style: italic;
|
||||
color: #1a202c;
|
||||
border-left: 4px solid #e2e8f0;
|
||||
quotes: initial;
|
||||
margin-top: 25px;
|
||||
margin-bottom: 25px;
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.prose h1 {
|
||||
color: #1a202c;
|
||||
font-weight: 800;
|
||||
font-size: 36px;
|
||||
margin-top: 0px;
|
||||
margin-bottom: 14px;
|
||||
line-height: 40px;
|
||||
}
|
||||
|
||||
.prose h2 {
|
||||
color: #1a202c;
|
||||
font-weight: 700;
|
||||
font-size: 24px;
|
||||
margin-top: 32px;
|
||||
margin-bottom: 16px;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.prose h3 {
|
||||
color: #1a202c;
|
||||
font-weight: 600;
|
||||
font-size: 20px;
|
||||
margin-top: 25px;
|
||||
margin-bottom: 9.6px;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.prose h4 {
|
||||
color: #1a202c;
|
||||
font-weight: 600;
|
||||
margin-top: 24px;
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.prose figure figcaption {
|
||||
color: #718096;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.prose code {
|
||||
color: #1a202c;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.prose code::before {
|
||||
content: "\`";
|
||||
}
|
||||
|
||||
.prose code::after {
|
||||
content: "\`";
|
||||
}
|
||||
|
||||
.prose pre {
|
||||
color: #e2e8f0;
|
||||
background-color: #2d3748;
|
||||
overflow-x: auto;
|
||||
font-size: 14px;
|
||||
line-height: 1.7142857;
|
||||
margin-top: 27px;
|
||||
margin-bottom: 27px;
|
||||
border-radius: 6px;
|
||||
padding-top: 13px;
|
||||
padding-right: 18px;
|
||||
padding-bottom: 13px;
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
.prose pre code {
|
||||
background-color: transparent;
|
||||
border-width: 0;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
font-weight: 400;
|
||||
color: inherit;
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.prose pre code::before {
|
||||
content: "";
|
||||
}
|
||||
|
||||
.prose pre code::after {
|
||||
content: "";
|
||||
}
|
||||
|
||||
.prose table {
|
||||
width: 100%;
|
||||
table-layout: auto;
|
||||
margin-top: 32px;
|
||||
margin-bottom: 32px;
|
||||
font-size: 11px;
|
||||
line-height: 1.7142857;
|
||||
}
|
||||
|
||||
.prose thead {
|
||||
color: #1a202c;
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid #cbd5e0;
|
||||
}
|
||||
|
||||
.prose thead th {
|
||||
vertical-align: bottom;
|
||||
padding-right: 9px;
|
||||
padding-bottom: 9px;
|
||||
padding-left: 9px;
|
||||
}
|
||||
|
||||
.prose tbody tr {
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.prose tbody tr:last-child {
|
||||
border-bottom-width: 0;
|
||||
}
|
||||
|
||||
.prose tbody td {
|
||||
vertical-align: top;
|
||||
padding-top: 9px;
|
||||
padding-right: 9px;
|
||||
padding-bottom: 9px;
|
||||
padding-left: 9px;
|
||||
}
|
||||
|
||||
.prose {
|
||||
font-size: 16px;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.prose p {
|
||||
margin-top: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.prose img {
|
||||
margin-top: 32px;
|
||||
margin-bottom: 32px;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.prose video {
|
||||
margin-top: 32px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.prose figure {
|
||||
margin-top: 32px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.prose figure > * {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.prose h2 code {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.prose h3 code {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.prose ul {
|
||||
margin-top: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.prose li {
|
||||
margin-top: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.prose ol > li:before {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.prose > ul > li p {
|
||||
margin-top: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.prose > ul > li > *:first-child {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.prose > ul > li > *:last-child {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.prose > ol > li > *:first-child {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.prose > ol > li > *:last-child {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.prose ul ul,
|
||||
.prose ul ol,
|
||||
.prose ol ul,
|
||||
.prose ol ol {
|
||||
margin-top: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.prose hr + * {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.prose h2 + * {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.prose h3 + * {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.prose h4 + * {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.prose thead th:first-child {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.prose thead th:last-child {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.prose tbody td:first-child {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.prose tbody td:last-child {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.prose > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.prose > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
</mj-style>
|
||||
</mj-head>
|
||||
<mj-body>
|
||||
<mj-section>
|
||||
<mj-column>
|
||||
<mj-raw>
|
||||
<tr class="prose prose-neutral">
|
||||
<td style="padding:10px 25px;word-break:break-word">
|
||||
${html}
|
||||
</td>
|
||||
</tr>
|
||||
</mj-raw>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section>
|
||||
<mj-column>
|
||||
${
|
||||
footer.unsubscribe
|
||||
? `
|
||||
<mj-divider border-width="2px" border-color="#f5f5f5"></mj-divider>
|
||||
<mj-text align="center">
|
||||
<p style="color: #a3a3a3; text-decoration: none; font-size: 12px; line-height: 1.7142857;">
|
||||
You received this email because you agreed to receive emails from ${project.name}. If you no longer wish to receive emails like this, please <a style="text-decoration: underline" href="https://${APP_URI}/unsubscribe/${contact.id}" target="_blank">update your preferences</a>.
|
||||
</p>
|
||||
</mj-text>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
</mj-body>
|
||||
</mjml>`,
|
||||
).html.replace(/^\s+|\s+$/g, "");
|
||||
}
|
||||
|
||||
public static format({
|
||||
subject,
|
||||
body,
|
||||
data,
|
||||
}: { subject: string; body: string; data: Record<string, string> }) {
|
||||
return {
|
||||
subject: subject.replace(/\{\{(.*?)}}/g, (match, key) => {
|
||||
const [mainKey, defaultValue] = key
|
||||
.split("??")
|
||||
.map((s: string) => s.trim());
|
||||
return data[mainKey] ?? defaultValue ?? "";
|
||||
}),
|
||||
body: body.replace(/\{\{(.*?)}}/g, (match, key) => {
|
||||
const [mainKey, defaultValue] = key
|
||||
.split("??")
|
||||
.map((s: string) => s.trim());
|
||||
if (Array.isArray(data[mainKey])) {
|
||||
return data[mainKey].map((e: string) => `<li>${e}</li>`).join("\n");
|
||||
}
|
||||
return data[mainKey] ?? defaultValue ?? "";
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
private static breakLongLines(input: string, maxLineLength: number): string {
|
||||
const lines = input.split("\n");
|
||||
const result = [];
|
||||
for (let line of lines) {
|
||||
while (line.length > maxLineLength) {
|
||||
let pos = maxLineLength;
|
||||
while (pos > 0 && line[pos] !== " ") {
|
||||
pos--;
|
||||
}
|
||||
if (pos === 0) {
|
||||
pos = maxLineLength;
|
||||
}
|
||||
result.push(line.substring(0, pos));
|
||||
line = line.substring(pos).trim();
|
||||
}
|
||||
result.push(line);
|
||||
}
|
||||
return result.join("\n");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import {prisma} from '../database/prisma';
|
||||
import {REDIS_ONE_MINUTE, wrapRedis} from './redis';
|
||||
import {Keys} from './keys';
|
||||
|
||||
export class EventService {
|
||||
public static id(id: string) {
|
||||
return wrapRedis(
|
||||
Keys.Event.id(id),
|
||||
() => {
|
||||
return prisma.event.findUnique({where: {id}});
|
||||
},
|
||||
REDIS_ONE_MINUTE * 1440,
|
||||
);
|
||||
}
|
||||
|
||||
public static event(projectId: string, name: string) {
|
||||
return wrapRedis(
|
||||
Keys.Event.event(projectId, name),
|
||||
() => {
|
||||
return prisma.event.findFirst({where: {projectId, name}});
|
||||
},
|
||||
REDIS_ONE_MINUTE * 1440,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { Role } from "@prisma/client";
|
||||
import { NODE_ENV } from "../app/constants";
|
||||
import { prisma } from "../database/prisma";
|
||||
import { Keys } from "./keys";
|
||||
import { redis, wrapRedis } from "./redis";
|
||||
|
||||
export class MembershipService {
|
||||
public static async isMember(projectId: string, userId: string) {
|
||||
return wrapRedis(
|
||||
Keys.ProjectMembership.isMember(projectId, userId),
|
||||
async () => {
|
||||
if (NODE_ENV === "development") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const membership = await prisma.projectMembership.findFirst({
|
||||
where: { projectId, userId },
|
||||
});
|
||||
|
||||
return !!membership;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
public static async isAdmin(projectId: string, userId: string) {
|
||||
return wrapRedis(
|
||||
Keys.ProjectMembership.isAdmin(projectId, userId),
|
||||
async () => {
|
||||
if (NODE_ENV === "development") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const membership = await prisma.projectMembership.findFirst({
|
||||
where: { projectId, userId, role: { in: ["ADMIN", "OWNER"] } },
|
||||
});
|
||||
|
||||
return !!membership;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
public static async isOwner(projectId: string, userId: string) {
|
||||
return wrapRedis(
|
||||
Keys.ProjectMembership.isOwner(projectId, userId),
|
||||
async () => {
|
||||
if (NODE_ENV === "development") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const membership = await prisma.projectMembership.findFirst({
|
||||
where: { projectId, userId, role: "OWNER" },
|
||||
});
|
||||
|
||||
return !!membership;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
public static async kick(projectId: string, userId: string) {
|
||||
await prisma.projectMembership.delete({
|
||||
where: { userId_projectId: { projectId, userId } },
|
||||
});
|
||||
|
||||
await redis.del(Keys.Project.memberships(projectId));
|
||||
await redis.del(Keys.User.projects(userId));
|
||||
}
|
||||
|
||||
public static async invite(projectId: string, userId: string, role: Role) {
|
||||
await prisma.projectMembership.create({
|
||||
data: { projectId, userId, role },
|
||||
});
|
||||
|
||||
await redis.del(Keys.Project.memberships(projectId));
|
||||
await redis.del(Keys.User.projects(userId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
import dayjs from "dayjs";
|
||||
import { prisma } from "../database/prisma";
|
||||
import { Keys } from "./keys";
|
||||
import { wrapRedis } from "./redis";
|
||||
|
||||
export class ProjectService {
|
||||
public static contacts = {
|
||||
get: (id: string) => {
|
||||
return wrapRedis(Keys.Project.contacts(id), async () => {
|
||||
return prisma.project.findUnique({ where: { id } }).contacts({
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
subscribed: true,
|
||||
createdAt: true,
|
||||
data: true,
|
||||
updatedAt: true,
|
||||
triggers: { select: { createdAt: true, eventId: true } },
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
paginated: (id: string, page: number) => {
|
||||
return wrapRedis(Keys.Project.contacts(id, { page }), async () => {
|
||||
return prisma.project.findUnique({ where: { id } }).contacts({
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
subscribed: true,
|
||||
createdAt: true,
|
||||
triggers: { select: { createdAt: true } },
|
||||
emails: { select: { createdAt: true } },
|
||||
},
|
||||
orderBy: [{ createdAt: "desc" }],
|
||||
take: 20,
|
||||
skip: (page - 1) * 20,
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
count: (id: string) => {
|
||||
return wrapRedis(Keys.Project.contacts(id, { count: true }), async () => {
|
||||
return prisma.contact.count({ where: { projectId: id } });
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
public static emails = {
|
||||
get: (id: string) => {
|
||||
return wrapRedis(Keys.Project.emails(id), async () => {
|
||||
return prisma.email.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ action: { projectId: id } },
|
||||
{ campaign: { projectId: id } },
|
||||
{ projectId: id },
|
||||
],
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
});
|
||||
},
|
||||
count: (id: string) => {
|
||||
return wrapRedis(Keys.Project.emails(id, { count: true }), async () => {
|
||||
return prisma.email.count({ where: { contact: { projectId: id } } });
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
public static id(id: string) {
|
||||
return wrapRedis(Keys.Project.id(id), async () => {
|
||||
return prisma.project.findUnique({ where: { id } });
|
||||
});
|
||||
}
|
||||
|
||||
public static key(key: string) {
|
||||
if (key.startsWith("sk_")) {
|
||||
return ProjectService.secret(key);
|
||||
}
|
||||
return ProjectService.public(key);
|
||||
}
|
||||
|
||||
public static secret(secretKey: string) {
|
||||
return wrapRedis(Keys.Project.secret(secretKey), () => {
|
||||
return prisma.project.findUnique({ where: { secret: secretKey } });
|
||||
});
|
||||
}
|
||||
|
||||
public static public(publicKey: string) {
|
||||
return wrapRedis(Keys.Project.public(publicKey), () => {
|
||||
return prisma.project.findUnique({ where: { public: publicKey } });
|
||||
});
|
||||
}
|
||||
|
||||
public static async secretIsAvailable(secretKey: string) {
|
||||
const project = await ProjectService.secret(secretKey);
|
||||
|
||||
return !project;
|
||||
}
|
||||
|
||||
public static async publicIsAvailable(publicKey: string) {
|
||||
const project = await ProjectService.public(publicKey);
|
||||
|
||||
return !project;
|
||||
}
|
||||
|
||||
public static memberships(id: string) {
|
||||
return wrapRedis(Keys.Project.memberships(id), async () => {
|
||||
const memberships = await prisma.project
|
||||
.findUnique({ where: { id } })
|
||||
.memberships({ include: { user: true } });
|
||||
|
||||
if (!memberships) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return memberships.map((membership) => {
|
||||
return {
|
||||
userId: membership.userId,
|
||||
email: membership.user.email,
|
||||
role: membership.role,
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public static metadata(id: string) {
|
||||
return wrapRedis(Keys.Project.metadata(id), async () => {
|
||||
const contacts = await prisma.project
|
||||
.findUnique({ where: { id } })
|
||||
.contacts({
|
||||
where: {
|
||||
data: {
|
||||
not: null,
|
||||
},
|
||||
},
|
||||
distinct: ["data"],
|
||||
select: {
|
||||
data: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!contacts) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
...new Set(
|
||||
contacts
|
||||
.filter((c) => c.data)
|
||||
.flatMap((c) => Object.keys(JSON.parse(c.data as string))),
|
||||
),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
public static async feed(id: string, page: number) {
|
||||
const itemsPerPage = 10;
|
||||
const skip = (page - 1) * itemsPerPage;
|
||||
|
||||
const triggers = await prisma.trigger.findMany({
|
||||
where: { contact: { projectId: id } },
|
||||
include: {
|
||||
contact: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
event: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
const emails = await prisma.email.findMany({
|
||||
where: { contact: { projectId: id } },
|
||||
include: {
|
||||
contact: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
const combined = [...triggers, ...emails];
|
||||
combined.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
|
||||
return combined.slice(skip, skip + itemsPerPage);
|
||||
}
|
||||
|
||||
public static usage(id: string) {
|
||||
return wrapRedis(Keys.Project.usage(id), async () => {
|
||||
const transactional = await prisma.email.count({
|
||||
where: {
|
||||
projectId: id,
|
||||
createdAt: {
|
||||
gte: new Date(dayjs().startOf("month").toISOString()),
|
||||
lte: new Date(dayjs().endOf("month").toISOString()),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const automation = await prisma.email.count({
|
||||
where: {
|
||||
action: { projectId: id },
|
||||
createdAt: {
|
||||
gte: new Date(dayjs().startOf("month").toISOString()),
|
||||
lte: new Date(dayjs().endOf("month").toISOString()),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const campaign = await prisma.email.count({
|
||||
where: {
|
||||
campaign: { projectId: id },
|
||||
createdAt: {
|
||||
gte: new Date(dayjs().startOf("month").toISOString()),
|
||||
lte: new Date(dayjs().endOf("month").toISOString()),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
transactional,
|
||||
automation,
|
||||
campaign,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
public static events(id: string, triggers: boolean) {
|
||||
return wrapRedis(Keys.Project.events(id, triggers), async () => {
|
||||
if (triggers) {
|
||||
return prisma.project.findUnique({ where: { id } }).events({
|
||||
include: {
|
||||
triggers: {
|
||||
select: { id: true, createdAt: true, contactId: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
}
|
||||
return prisma.project.findUnique({ where: { id } }).events({
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public static actions(id: string) {
|
||||
return wrapRedis(Keys.Project.actions(id), async () => {
|
||||
return prisma.project.findUnique({ where: { id } }).actions({
|
||||
include: {
|
||||
triggers: { select: { id: true } },
|
||||
template: true,
|
||||
emails: { select: { id: true, status: true } },
|
||||
tasks: { select: { id: true } },
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public static templates(id: string) {
|
||||
return wrapRedis(Keys.Project.templates(id), async () => {
|
||||
return prisma.project.findUnique({ where: { id } }).templates({
|
||||
include: { actions: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public static campaigns(id: string) {
|
||||
return wrapRedis(Keys.Project.campaigns(id), async () => {
|
||||
return prisma.project.findUnique({ where: { id } }).campaigns({
|
||||
include: {
|
||||
recipients: { select: { id: true } },
|
||||
emails: { select: { id: true, status: true } },
|
||||
tasks: { select: { id: true } },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public static analytics(params: {
|
||||
id: string;
|
||||
method?: "week" | "month" | "year";
|
||||
}) {
|
||||
return wrapRedis(Keys.Project.analytics(params.id), async () => {
|
||||
const methods = {
|
||||
week: {
|
||||
daysBack: 7,
|
||||
method: "week",
|
||||
},
|
||||
month: {
|
||||
daysBack: 30,
|
||||
method: "day",
|
||||
},
|
||||
year: {
|
||||
daysBack: 365,
|
||||
method: "month",
|
||||
},
|
||||
};
|
||||
|
||||
const end = dayjs().toDate();
|
||||
const start = dayjs()
|
||||
.subtract(methods[params.method ?? "week"].daysBack, "days")
|
||||
.toDate();
|
||||
|
||||
const contacts = await prisma.$queryRaw`
|
||||
WITH date_range AS (
|
||||
SELECT generate_series(
|
||||
(SELECT DATE_TRUNC('day', MIN("createdAt")) FROM contacts),
|
||||
DATE_TRUNC('day', NOW()) + INTERVAL '1 day',
|
||||
INTERVAL '1 day'
|
||||
) AS day
|
||||
)
|
||||
|
||||
SELECT
|
||||
dr.day,
|
||||
SUM(COALESCE(ct.count, 0)) OVER (ORDER BY dr.day) as count
|
||||
FROM date_range dr
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
DATE_TRUNC('day', c."createdAt") AS day,
|
||||
COUNT(c.id) as count
|
||||
FROM contacts c
|
||||
WHERE "projectId" = ${params.id}
|
||||
GROUP BY DATE_TRUNC('day', c."createdAt")
|
||||
) ct ON dr.day = ct.day
|
||||
WHERE dr.day < DATE_TRUNC('day', NOW())
|
||||
ORDER BY dr.day DESC
|
||||
LIMIT 30;
|
||||
|
||||
`;
|
||||
|
||||
const rawActionClicks = await prisma.$queryRaw`
|
||||
SELECT clicks."link", a."name", count(clicks.id)::int FROM clicks
|
||||
JOIN emails e on clicks."emailId" = e.id
|
||||
JOIN actions a on e."actionId" = a.id
|
||||
WHERE clicks."link" NOT LIKE '%unsubscribe%' AND DATE(clicks."createdAt") BETWEEN DATE(${start}) AND DATE(${end}) AND a."projectId" = ${params.id}
|
||||
GROUP BY a."name", clicks."link"
|
||||
`;
|
||||
|
||||
const combinedRoutes = {};
|
||||
|
||||
// @ts-expect-error
|
||||
rawActionClicks.forEach((item) => {
|
||||
const url = new URL(item.link);
|
||||
const route = url.pathname;
|
||||
// @ts-expect-error
|
||||
if (combinedRoutes[route]) {
|
||||
// @ts-expect-error
|
||||
combinedRoutes[route].count += item.count;
|
||||
} else {
|
||||
// @ts-expect-error
|
||||
combinedRoutes[route] = {
|
||||
link: url.hostname + route,
|
||||
name: item.name,
|
||||
count: item.count,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const formattedActionClicks = Object.values(combinedRoutes).sort(
|
||||
// @ts-expect-error
|
||||
(a, b) => b.count - a.count,
|
||||
);
|
||||
|
||||
const subscribed = await prisma.contact.count({
|
||||
where: { subscribed: true, projectId: params.id },
|
||||
});
|
||||
const unsubscribed = await prisma.contact.count({
|
||||
where: { subscribed: false, projectId: params.id },
|
||||
});
|
||||
|
||||
const opened = await prisma.email.count({
|
||||
where: {
|
||||
contact: { projectId: params.id },
|
||||
status: "OPENED",
|
||||
},
|
||||
});
|
||||
|
||||
const openedPrev = await prisma.email.count({
|
||||
where: {
|
||||
contact: { projectId: params.id },
|
||||
status: "OPENED",
|
||||
createdAt: {
|
||||
lte: start,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const bounced = await prisma.email.count({
|
||||
where: {
|
||||
contact: { projectId: params.id },
|
||||
status: "BOUNCED",
|
||||
},
|
||||
});
|
||||
|
||||
const bouncedPrev = await prisma.email.count({
|
||||
where: {
|
||||
contact: { projectId: params.id },
|
||||
status: "BOUNCED",
|
||||
createdAt: {
|
||||
lte: start,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const complaint = await prisma.email.count({
|
||||
where: {
|
||||
contact: { projectId: params.id },
|
||||
status: "COMPLAINT",
|
||||
},
|
||||
});
|
||||
|
||||
const complaintPrev = await prisma.email.count({
|
||||
where: {
|
||||
contact: { projectId: params.id },
|
||||
status: "COMPLAINT",
|
||||
createdAt: {
|
||||
lte: start,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const total = await prisma.email.count({
|
||||
where: {
|
||||
contact: { projectId: params.id },
|
||||
},
|
||||
});
|
||||
|
||||
const totalPrev = await prisma.email.count({
|
||||
where: {
|
||||
contact: { projectId: params.id },
|
||||
createdAt: {
|
||||
lte: start,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
contacts: { timeseries: contacts, subscribed, unsubscribed },
|
||||
emails: {
|
||||
total,
|
||||
opened,
|
||||
bounced,
|
||||
complaint,
|
||||
totalPrev,
|
||||
bouncedPrev,
|
||||
complaintPrev,
|
||||
openedPrev,
|
||||
},
|
||||
clicks: {
|
||||
actions: formattedActionClicks,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import {wrapRedis} from './redis';
|
||||
import {prisma} from '../database/prisma';
|
||||
import {Keys} from './keys';
|
||||
|
||||
export class TemplateService {
|
||||
public static id(id: string) {
|
||||
return wrapRedis(Keys.Template.id(id), async () => {
|
||||
return prisma.template.findUnique({where: {id}, include: {actions: true}});
|
||||
});
|
||||
}
|
||||
|
||||
public static actions(templateId: string) {
|
||||
return wrapRedis(Keys.Template.actions(templateId), async () => {
|
||||
return prisma.template.findUnique({where: {id: templateId}}).actions();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import dayjs from "dayjs";
|
||||
import { NODE_ENV } from "../app/constants";
|
||||
import { prisma } from "../database/prisma";
|
||||
import { Keys } from "./keys";
|
||||
import { wrapRedis } from "./redis";
|
||||
|
||||
export class UserService {
|
||||
public static readonly COOKIE_NAME = "token";
|
||||
|
||||
public static id(id: string) {
|
||||
return wrapRedis(Keys.User.id(id), () => {
|
||||
return prisma.user.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public static email(email: string) {
|
||||
return wrapRedis(Keys.User.email(email), () => {
|
||||
return prisma.user.findUnique({
|
||||
where: { email },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public static async projects(id: string) {
|
||||
return wrapRedis(Keys.User.projects(id), async () => {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id },
|
||||
include: { memberships: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return prisma.project.findMany({
|
||||
where: {
|
||||
id: {
|
||||
in: user.memberships.map((project) => project.projectId),
|
||||
},
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates cookie options
|
||||
* @param expires An optional expiry for this cookie (useful for a logout)
|
||||
*/
|
||||
public static cookieOptions(expires?: Date) {
|
||||
return {
|
||||
httpOnly: true,
|
||||
expires: expires ?? dayjs().add(168, "hours").toDate(),
|
||||
secure: NODE_ENV !== "development",
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
} as const;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
export const Keys = {
|
||||
User: {
|
||||
id(id: string): string {
|
||||
return `account:id:${id}`;
|
||||
},
|
||||
email(email: string): string {
|
||||
return `account:${email}`;
|
||||
},
|
||||
projects(id: string): string {
|
||||
return `account:${id}:projects`;
|
||||
},
|
||||
},
|
||||
Project: {
|
||||
id(id: string): string {
|
||||
return `project:id:${id}`;
|
||||
},
|
||||
secret(secretKey: string): string {
|
||||
return `project:secret:${secretKey}`;
|
||||
},
|
||||
public(publicKey: string): string {
|
||||
return `project:public:${publicKey}`;
|
||||
},
|
||||
memberships(id: string): string {
|
||||
return `project:${id}:memberships`;
|
||||
},
|
||||
usage(id: string): string {
|
||||
return `project:${id}:usage`;
|
||||
},
|
||||
events(id: string, triggers: boolean): string {
|
||||
if (triggers) {
|
||||
return `project:${id}:events:triggers`;
|
||||
}
|
||||
|
||||
return `project:${id}:events`;
|
||||
},
|
||||
metadata(id: string): string {
|
||||
return `project:${id}:metadata`;
|
||||
},
|
||||
actions(id: string): string {
|
||||
return `project:${id}:actions`;
|
||||
},
|
||||
templates(id: string): string {
|
||||
return `project:${id}:templates`;
|
||||
},
|
||||
feed(id: string): string {
|
||||
return `project:${id}:feed`;
|
||||
},
|
||||
contacts(
|
||||
id: string,
|
||||
options?: {
|
||||
page?: number;
|
||||
count?: boolean;
|
||||
},
|
||||
): string {
|
||||
if (options?.count) {
|
||||
return `project:${id}:contacts:count`;
|
||||
}
|
||||
|
||||
if (options?.page) {
|
||||
return `project:${id}:contacts:page:${options.page}`;
|
||||
}
|
||||
|
||||
return `project:${id}:contacts`;
|
||||
},
|
||||
campaigns(id: string): string {
|
||||
return `project:${id}:campaigns`;
|
||||
},
|
||||
analytics(id: string): string {
|
||||
return `project:${id}:analytics`;
|
||||
},
|
||||
emails(
|
||||
id: string,
|
||||
options?: {
|
||||
count?: boolean;
|
||||
},
|
||||
): string {
|
||||
if (options?.count) {
|
||||
return `project:${id}:emails:count`;
|
||||
}
|
||||
|
||||
return `project:${id}:emails`;
|
||||
},
|
||||
},
|
||||
ProjectMembership: {
|
||||
isMember(projectId: string, accountId: string) {
|
||||
return `project:id:${projectId}:ismember:${accountId}`;
|
||||
},
|
||||
isAdmin(projectId: string, accountId: string) {
|
||||
return `project:id:${projectId}:isadmin:${accountId}`;
|
||||
},
|
||||
isOwner(projectId: string, accountId: string) {
|
||||
return `project:id:${projectId}:isowner:${accountId}`;
|
||||
},
|
||||
},
|
||||
Campaign: {
|
||||
id(id: string): string {
|
||||
return `campaign:id:${id}`;
|
||||
},
|
||||
},
|
||||
Template: {
|
||||
id(id: string): string {
|
||||
return `template:id:${id}`;
|
||||
},
|
||||
actions(templateId: string): string {
|
||||
return `template:id:${templateId}:actions`;
|
||||
},
|
||||
},
|
||||
Webhook: {
|
||||
id(id: string): string {
|
||||
return `webhook:id:${id}`;
|
||||
},
|
||||
},
|
||||
Contact: {
|
||||
id(id: string): string {
|
||||
return `contact:id:${id}`;
|
||||
},
|
||||
email(projectId: string, email: string): string {
|
||||
return `project:id:${projectId}:contact:email:${email}`;
|
||||
},
|
||||
},
|
||||
Action: {
|
||||
id(id: string): string {
|
||||
return `action:id:${id}`;
|
||||
},
|
||||
related(id: string): string {
|
||||
return `action:id:${id}:related`;
|
||||
},
|
||||
event(eventId: string): string {
|
||||
return `action:event:id:${eventId}`;
|
||||
},
|
||||
},
|
||||
Event: {
|
||||
id(id: string): string {
|
||||
return `event:id:${id}`;
|
||||
},
|
||||
event(projectId: string, name: string): string {
|
||||
return `project:id:${projectId}:event:name:${name}`;
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import Redis from 'ioredis';
|
||||
import {REDIS_URL} from '../app/constants';
|
||||
|
||||
export const redis = new Redis(REDIS_URL);
|
||||
|
||||
export const REDIS_ONE_MINUTE = 60;
|
||||
export const REDIS_DEFAULT_EXPIRY = REDIS_ONE_MINUTE / 60;
|
||||
|
||||
/**
|
||||
* @param key The key for redis (use Keys#<type>)
|
||||
* @param fn The function to return a resource. Can be a promise
|
||||
* @param seconds The amount of seconds to hold this resource in redis for. Defaults to 60
|
||||
*/
|
||||
export async function wrapRedis<T>(key: string, fn: () => Promise<T>, seconds = REDIS_DEFAULT_EXPIRY): Promise<T> {
|
||||
const cached = await redis.get(key);
|
||||
if (cached) {
|
||||
return JSON.parse(cached);
|
||||
}
|
||||
|
||||
const recent = await fn();
|
||||
|
||||
if (recent) {
|
||||
await redis.set(key, JSON.stringify(recent), 'EX', seconds);
|
||||
}
|
||||
|
||||
return recent;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import bcrypt from "bcrypt";
|
||||
|
||||
/**
|
||||
* Verifies a hash against a password
|
||||
* @param {string} pass The password
|
||||
* @param {string} hash The hash
|
||||
*/
|
||||
export const verifyHash = (pass: string, hash: string) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
void bcrypt.compare(pass, hash, (err, res) => {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
return resolve(res);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates a hash from plain text
|
||||
* @param {string} pass The password
|
||||
* @returns {Promise<string>} Password hash
|
||||
*/
|
||||
export const createHash = (pass: string): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
void bcrypt.hash(pass, 10, (err, res) => {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
resolve(res);
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import { SES } from "@aws-sdk/client-ses";
|
||||
import {
|
||||
AWS_ACCESS_KEY_ID,
|
||||
AWS_REGION,
|
||||
AWS_SECRET_ACCESS_KEY,
|
||||
} from "../app/constants";
|
||||
|
||||
export const ses = new SES({
|
||||
apiVersion: "2010-12-01",
|
||||
region: AWS_REGION,
|
||||
credentials: {
|
||||
accessKeyId: AWS_ACCESS_KEY_ID,
|
||||
secretAccessKey: AWS_SECRET_ACCESS_KEY,
|
||||
},
|
||||
});
|
||||
|
||||
export const getIdentities = async (identities: string[]) => {
|
||||
const res = await ses.getIdentityVerificationAttributes({
|
||||
Identities: identities.flatMap((identity) => [identity.split("@")[1]]),
|
||||
});
|
||||
|
||||
const parsedResult = Object.entries(res.VerificationAttributes ?? {});
|
||||
return parsedResult.map((obj) => {
|
||||
return { email: obj[0], status: obj[1].VerificationStatus };
|
||||
});
|
||||
};
|
||||
|
||||
export const verifyIdentity = async (email: string) => {
|
||||
const DKIM = await ses.verifyDomainDkim({
|
||||
Domain: email.includes("@") ? email.split("@")[1] : email,
|
||||
});
|
||||
|
||||
await ses.setIdentityMailFromDomain({
|
||||
Identity: email.includes("@") ? email.split("@")[1] : email,
|
||||
MailFromDomain: `plunk.${email.includes("@") ? email.split("@")[1] : email}`,
|
||||
});
|
||||
|
||||
return DKIM.DkimTokens;
|
||||
};
|
||||
|
||||
export const getIdentityVerificationAttributes = async (email: string) => {
|
||||
const attributes = await ses.getIdentityDkimAttributes({
|
||||
Identities: [email, email.split("@")[1]],
|
||||
});
|
||||
|
||||
const parsedAttributes = Object.entries(attributes.DkimAttributes ?? {});
|
||||
|
||||
return {
|
||||
email: parsedAttributes[0][0],
|
||||
tokens: parsedAttributes[0][1].DkimTokens,
|
||||
status: parsedAttributes[0][1].DkimVerificationStatus,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
|
||||
/**
|
||||
* A function that generates a random 24 byte API secret
|
||||
* @param type
|
||||
*/
|
||||
export function generateToken(type: "secret" | "public") {
|
||||
return `${type === "secret" ? "sk" : "pk"}_${randomBytes(24).toString("hex")}`;
|
||||
}
|
||||
Reference in New Issue
Block a user