Update Biome formatting settings, improved self-hosting capability and reformatted all files
This commit is contained in:
+12
-14
@@ -82,22 +82,20 @@ server.app.use((req, res, next) => {
|
||||
next();
|
||||
});
|
||||
|
||||
server.app.use(
|
||||
(error: Error, req: Request, res: Response, _next: NextFunction) => {
|
||||
const code = error instanceof HttpException ? error.code : 500;
|
||||
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);
|
||||
}
|
||||
if (NODE_ENV !== "development") {
|
||||
signale.error(error);
|
||||
}
|
||||
|
||||
res.status(code).json({
|
||||
code,
|
||||
error: STATUS_CODES[code],
|
||||
message: error.message,
|
||||
time: Date.now(),
|
||||
});
|
||||
},
|
||||
);
|
||||
res.status(code).json({
|
||||
code,
|
||||
error: STATUS_CODES[code],
|
||||
message: error.message,
|
||||
time: Date.now(),
|
||||
});
|
||||
});
|
||||
|
||||
void prisma.$connect().then(() => {
|
||||
server.app.listen(4000, () => {
|
||||
|
||||
@@ -3,10 +3,7 @@
|
||||
* @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 {
|
||||
export function validateEnv<T extends string = string>(key: keyof NodeJS.ProcessEnv, defaultValue?: T): T {
|
||||
const value = process.env[key] as T | undefined;
|
||||
|
||||
if (!value) {
|
||||
@@ -21,10 +18,7 @@ export function validateEnv<T extends string = string>(
|
||||
|
||||
// ENV
|
||||
export const JWT_SECRET = validateEnv("JWT_SECRET");
|
||||
export const NODE_ENV = validateEnv<"development" | "production">(
|
||||
"NODE_ENV",
|
||||
"production",
|
||||
);
|
||||
export const NODE_ENV = validateEnv<"development" | "production">("NODE_ENV", "production");
|
||||
|
||||
export const REDIS_URL = validateEnv("REDIS_URL");
|
||||
|
||||
@@ -36,6 +30,4 @@ export const APP_URI = validateEnv("APP_URI", "http://localhost:3000");
|
||||
export const AWS_REGION = validateEnv("AWS_REGION");
|
||||
export const AWS_ACCESS_KEY_ID = validateEnv("AWS_ACCESS_KEY_ID");
|
||||
export const AWS_SECRET_ACCESS_KEY = validateEnv("AWS_SECRET_ACCESS_KEY");
|
||||
export const AWS_SES_CONFIGURATION_SET = validateEnv(
|
||||
"AWS_SES_CONFIGURATION_SET",
|
||||
);
|
||||
export const AWS_SES_CONFIGURATION_SET = validateEnv("AWS_SES_CONFIGURATION_SET");
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import cron from 'node-cron';
|
||||
import {API_URI} from './constants';
|
||||
import signale from 'signale';
|
||||
import cron from "node-cron";
|
||||
import signale from "signale";
|
||||
import { API_URI } from "./constants";
|
||||
|
||||
export const task = cron.schedule('* * * * *', () => {
|
||||
signale.info('Running scheduled tasks');
|
||||
void fetch(`${API_URI}/tasks`, {
|
||||
method: 'POST',
|
||||
});
|
||||
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',
|
||||
});
|
||||
signale.info("Updating verified identities");
|
||||
void fetch(`${API_URI}/identities/update`, {
|
||||
method: "POST",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,12 +35,7 @@ export class Auth {
|
||||
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,
|
||||
);
|
||||
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();
|
||||
@@ -70,12 +65,7 @@ export class Auth {
|
||||
},
|
||||
});
|
||||
|
||||
await redis.set(
|
||||
Keys.User.id(created_user.id),
|
||||
JSON.stringify(created_user),
|
||||
"EX",
|
||||
REDIS_ONE_MINUTE * 60,
|
||||
);
|
||||
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();
|
||||
@@ -88,9 +78,7 @@ export class Auth {
|
||||
|
||||
@Post("reset")
|
||||
public async reset(req: Request, res: Response) {
|
||||
const { id, password } = UtilitySchemas.id
|
||||
.merge(UserSchemas.credentials.pick({ password: true }))
|
||||
.parse(req.body);
|
||||
const { id, password } = UtilitySchemas.id.merge(UserSchemas.credentials.pick({ password: true })).parse(req.body);
|
||||
|
||||
const user = await UserService.id(id);
|
||||
|
||||
@@ -115,11 +103,7 @@ export class Auth {
|
||||
|
||||
@Get("logout")
|
||||
public logout(req: Request, res: Response) {
|
||||
res.cookie(
|
||||
UserService.COOKIE_NAME,
|
||||
"",
|
||||
UserService.cookieOptions(new Date()),
|
||||
);
|
||||
res.cookie(UserService.COOKIE_NAME, "", UserService.cookieOptions(new Date()));
|
||||
return res.json(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,12 +8,7 @@ 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";
|
||||
import { getIdentities, getIdentityVerificationAttributes, ses, verifyIdentity } from "../util/ses";
|
||||
|
||||
@Controller("identities")
|
||||
export class Identities {
|
||||
@@ -117,14 +112,10 @@ export class Identities {
|
||||
take: 99,
|
||||
});
|
||||
|
||||
const awsIdentities = await getIdentities(
|
||||
dbIdentities.map((i) => i.email as string),
|
||||
);
|
||||
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 projectId = dbIdentities.find((i) => i.email?.endsWith(identity.email));
|
||||
|
||||
const project = await ProjectService.id(projectId?.id as string);
|
||||
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
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 { HttpException, NotAllowed, NotAuthenticated, NotFound } from "../exceptions";
|
||||
import { type IJwt, isAuthenticated } from "../middleware/auth";
|
||||
import { MembershipService } from "../services/MembershipService";
|
||||
import { ProjectService } from "../services/ProjectService";
|
||||
@@ -38,16 +33,10 @@ export class Memberships {
|
||||
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.",
|
||||
);
|
||||
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,
|
||||
);
|
||||
const alreadyMember = await MembershipService.isMember(project.id, invitedUser.id);
|
||||
|
||||
if (alreadyMember) {
|
||||
throw new NotAllowed();
|
||||
@@ -91,10 +80,7 @@ export class Memberships {
|
||||
throw new NotFound("user");
|
||||
}
|
||||
|
||||
const isMember = await MembershipService.isMember(
|
||||
project.id,
|
||||
kickedUser.id,
|
||||
);
|
||||
const isMember = await MembershipService.isMember(project.id, kickedUser.id);
|
||||
|
||||
if (!isMember) {
|
||||
throw new NotAllowed();
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import {
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Middleware,
|
||||
Post,
|
||||
Put,
|
||||
} from "@overnightjs/core";
|
||||
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";
|
||||
@@ -197,10 +190,7 @@ export class Projects {
|
||||
const contacts = await prisma.contact.findMany({
|
||||
where: {
|
||||
projectId: project.id,
|
||||
OR: [
|
||||
{ email: { contains: query, mode: "insensitive" } },
|
||||
{ data: { contains: query, mode: "insensitive" } },
|
||||
],
|
||||
OR: [{ email: { contains: query, mode: "insensitive" } }, { data: { contains: query, mode: "insensitive" } }],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
|
||||
@@ -46,13 +46,7 @@ export class Tasks {
|
||||
|
||||
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,
|
||||
),
|
||||
)
|
||||
) {
|
||||
if (notevents.some((e) => triggers.some((t) => t.contactId === contact.id && t.eventId === e.id))) {
|
||||
await prisma.task.delete({ where: { id: task.id } });
|
||||
continue;
|
||||
}
|
||||
@@ -82,10 +76,7 @@ export class Tasks {
|
||||
const { messageId } = await EmailService.send({
|
||||
from: {
|
||||
name: project.from ?? project.name,
|
||||
email:
|
||||
project.verified && project.email
|
||||
? project.email
|
||||
: "no-reply@useplunk.dev",
|
||||
email: project.verified && project.email ? project.email : "no-reply@useplunk.dev",
|
||||
},
|
||||
to: [contact.email],
|
||||
content: {
|
||||
@@ -93,9 +84,7 @@ export class Tasks {
|
||||
html: EmailService.compile({
|
||||
content: body,
|
||||
footer: {
|
||||
unsubscribe: campaign
|
||||
? true
|
||||
: !!action && action.template.type === "MARKETING",
|
||||
unsubscribe: campaign ? true : !!action && action.template.type === "MARKETING",
|
||||
},
|
||||
contact: {
|
||||
id: contact.id,
|
||||
@@ -103,9 +92,7 @@ export class Tasks {
|
||||
project: {
|
||||
name: project.name,
|
||||
},
|
||||
isHtml:
|
||||
(campaign && campaign.style === "HTML") ??
|
||||
(!!action && action.template.style === "HTML"),
|
||||
isHtml: (campaign && campaign.style === "HTML") ?? (!!action && action.template.style === "HTML"),
|
||||
}),
|
||||
},
|
||||
});
|
||||
@@ -130,9 +117,7 @@ export class Tasks {
|
||||
|
||||
await prisma.task.delete({ where: { id: task.id } });
|
||||
|
||||
signale.success(
|
||||
`Task completed for ${contact.email} from ${project.name}`,
|
||||
);
|
||||
signale.success(`Task completed for ${contact.email} from ${project.name}`);
|
||||
}
|
||||
|
||||
return res.status(200).json({ success: true });
|
||||
|
||||
@@ -43,33 +43,24 @@ export class SNSWebhook {
|
||||
// The email was a transactional email
|
||||
if (email.projectId) {
|
||||
if (body.eventType === "Click") {
|
||||
signale.success(
|
||||
`Click received for ${email.contact.email} from ${project.name}`,
|
||||
);
|
||||
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}`,
|
||||
);
|
||||
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}`,
|
||||
);
|
||||
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"
|
||||
],
|
||||
status: eventMap[body.eventType as "Bounce" | "Delivery" | "Open" | "Complaint"],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -95,9 +86,7 @@ export class SNSWebhook {
|
||||
}
|
||||
|
||||
if (body.eventType === "Click") {
|
||||
signale.success(
|
||||
`Click received for ${email.contact.email} from ${project.name}`,
|
||||
);
|
||||
signale.success(`Click received for ${email.contact.email} from ${project.name}`);
|
||||
|
||||
await prisma.click.create({
|
||||
data: { emailId: email.id, link: body.click.link },
|
||||
@@ -111,12 +100,7 @@ export class SNSWebhook {
|
||||
if (email.action) {
|
||||
event = email.action.template.events.find((e) =>
|
||||
e.name.includes(
|
||||
(body.eventType as
|
||||
| "Bounce"
|
||||
| "Delivery"
|
||||
| "Open"
|
||||
| "Complaint"
|
||||
| "Click") === "Delivery"
|
||||
(body.eventType as "Bounce" | "Delivery" | "Open" | "Complaint" | "Click") === "Delivery"
|
||||
? "delivered"
|
||||
: "opened",
|
||||
),
|
||||
@@ -126,12 +110,7 @@ export class SNSWebhook {
|
||||
if (email.campaign) {
|
||||
event = email.campaign.events.find((e) =>
|
||||
e.name.includes(
|
||||
(body.eventType as
|
||||
| "Bounce"
|
||||
| "Delivery"
|
||||
| "Open"
|
||||
| "Complaint"
|
||||
| "Click") === "Delivery"
|
||||
(body.eventType as "Bounce" | "Delivery" | "Open" | "Complaint" | "Click") === "Delivery"
|
||||
? "delivered"
|
||||
: "opened",
|
||||
),
|
||||
@@ -144,9 +123,7 @@ export class SNSWebhook {
|
||||
|
||||
switch (body.eventType as "Delivery" | "Open") {
|
||||
case "Delivery":
|
||||
signale.success(
|
||||
`Delivery received for ${email.contact.email} from ${project.name}`,
|
||||
);
|
||||
signale.success(`Delivery received for ${email.contact.email} from ${project.name}`);
|
||||
await prisma.email.update({
|
||||
where: { messageId: body.mail.messageId },
|
||||
data: { status: "DELIVERED" },
|
||||
@@ -158,9 +135,7 @@ export class SNSWebhook {
|
||||
|
||||
break;
|
||||
case "Open":
|
||||
signale.success(
|
||||
`Open received for ${email.contact.email} from ${project.name}`,
|
||||
);
|
||||
signale.success(`Open received for ${email.contact.email} from ${project.name}`);
|
||||
await prisma.email.update({
|
||||
where: { messageId: body.mail.messageId },
|
||||
data: { status: "OPENED" },
|
||||
|
||||
@@ -1,21 +1,9 @@
|
||||
import {
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Middleware,
|
||||
Post,
|
||||
Put,
|
||||
} from "@overnightjs/core";
|
||||
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 { type IJwt, type ISecret, isAuthenticated, isValidSecretKey } from "../../middleware/auth";
|
||||
import { ActionService } from "../../services/ActionService";
|
||||
import { EventService } from "../../services/EventService";
|
||||
import { MembershipService } from "../../services/MembershipService";
|
||||
@@ -83,14 +71,7 @@ export class Actions {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const {
|
||||
name,
|
||||
runOnce,
|
||||
delay,
|
||||
template: templateId,
|
||||
events,
|
||||
notevents,
|
||||
} = ActionSchemas.create.parse(req.body);
|
||||
const { name, runOnce, delay, template: templateId, events, notevents } = ActionSchemas.create.parse(req.body);
|
||||
|
||||
const template = await TemplateService.id(templateId);
|
||||
|
||||
@@ -160,15 +141,7 @@ export class Actions {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const {
|
||||
id,
|
||||
template: templateId,
|
||||
events,
|
||||
notevents,
|
||||
name,
|
||||
runOnce,
|
||||
delay,
|
||||
} = ActionSchemas.update.parse(req.body);
|
||||
const { id, template: templateId, events, notevents, name, runOnce, delay } = ActionSchemas.update.parse(req.body);
|
||||
|
||||
let action = await ActionService.id(id);
|
||||
|
||||
|
||||
@@ -1,22 +1,10 @@
|
||||
import {
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Middleware,
|
||||
Post,
|
||||
Put,
|
||||
} from "@overnightjs/core";
|
||||
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 { type IJwt, type ISecret, isAuthenticated, isValidSecretKey } from "../../middleware/auth";
|
||||
import { CampaignService } from "../../services/CampaignService";
|
||||
import { EmailService } from "../../services/EmailService";
|
||||
import { MembershipService } from "../../services/MembershipService";
|
||||
@@ -39,10 +27,7 @@ export class Campaigns {
|
||||
throw new NotFound("campaign");
|
||||
}
|
||||
|
||||
const isMember = await MembershipService.isMember(
|
||||
campaign.projectId,
|
||||
userId,
|
||||
);
|
||||
const isMember = await MembershipService.isMember(campaign.projectId, userId);
|
||||
|
||||
if (!isMember) {
|
||||
throw new NotFound("campaign");
|
||||
@@ -122,10 +107,7 @@ export class Campaigns {
|
||||
await EmailService.send({
|
||||
from: {
|
||||
name: project.from ?? project.name,
|
||||
email:
|
||||
project.verified && project.email
|
||||
? project.email
|
||||
: "no-reply@useplunk.dev",
|
||||
email: project.verified && project.email ? project.email : "no-reply@useplunk.dev",
|
||||
},
|
||||
to: members.map((m) => m.email),
|
||||
content: {
|
||||
@@ -197,9 +179,7 @@ export class Campaigns {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
let { subject, body, recipients, style } = CampaignSchemas.create.parse(
|
||||
req.body,
|
||||
);
|
||||
let { subject, body, recipients, style } = CampaignSchemas.create.parse(req.body);
|
||||
|
||||
if (recipients.length === 1 && recipients[0] === "all") {
|
||||
const projectContacts = await prisma.contact.findMany({
|
||||
@@ -251,9 +231,7 @@ export class Campaigns {
|
||||
}
|
||||
|
||||
// eslint-disable-next-line prefer-const
|
||||
let { id, subject, body, recipients, style } = CampaignSchemas.update.parse(
|
||||
req.body,
|
||||
);
|
||||
let { id, subject, body, recipients, style } = CampaignSchemas.update.parse(req.body);
|
||||
|
||||
if (recipients.length === 1 && recipients[0] === "all") {
|
||||
const projectContacts = await prisma.contact.findMany({
|
||||
|
||||
@@ -1,22 +1,10 @@
|
||||
import {
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Middleware,
|
||||
Post,
|
||||
Put,
|
||||
} from "@overnightjs/core";
|
||||
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 { type IKey, type ISecret, isValidKey, isValidSecretKey } from "../../middleware/auth";
|
||||
import { ActionService } from "../../services/ActionService";
|
||||
import { ContactService } from "../../services/ContactService";
|
||||
import { EventService } from "../../services/EventService";
|
||||
@@ -150,9 +138,7 @@ export class Contacts {
|
||||
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 });
|
||||
return res.status(200).json({ success: true, contact: contact.id, subscribed: false });
|
||||
}
|
||||
|
||||
@Post("subscribe")
|
||||
@@ -203,9 +189,7 @@ export class Contacts {
|
||||
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 });
|
||||
return res.status(200).json({ success: true, contact: contact.id, subscribed: true });
|
||||
}
|
||||
|
||||
@Post()
|
||||
@@ -262,9 +246,7 @@ export class Contacts {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const { id, email, subscribed, data } = ContactSchemas.update.parse(
|
||||
req.body,
|
||||
);
|
||||
const { id, email, subscribed, data } = ContactSchemas.update.parse(req.body);
|
||||
|
||||
let contact = await ContactService.id(id);
|
||||
|
||||
|
||||
@@ -1,22 +1,9 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import {
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Middleware,
|
||||
Post,
|
||||
Put,
|
||||
} from "@overnightjs/core";
|
||||
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 { type IJwt, type ISecret, isAuthenticated, isValidSecretKey } from "../../middleware/auth";
|
||||
import { MembershipService } from "../../services/MembershipService";
|
||||
import { ProjectService } from "../../services/ProjectService";
|
||||
import { TemplateService } from "../../services/TemplateService";
|
||||
@@ -38,10 +25,7 @@ export class Templates {
|
||||
throw new NotFound("template");
|
||||
}
|
||||
|
||||
const isMember = await MembershipService.isMember(
|
||||
template.projectId,
|
||||
userId,
|
||||
);
|
||||
const isMember = await MembershipService.isMember(template.projectId, userId);
|
||||
|
||||
if (!isMember) {
|
||||
throw new NotFound("template");
|
||||
@@ -117,9 +101,7 @@ export class Templates {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const { subject, body, type, style } = TemplateSchemas.create.parse(
|
||||
req.body,
|
||||
);
|
||||
const { subject, body, type, style } = TemplateSchemas.create.parse(req.body);
|
||||
|
||||
const template = await prisma.template.create({
|
||||
data: {
|
||||
@@ -169,9 +151,7 @@ export class Templates {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const { id, subject, body, type, style } = TemplateSchemas.update.parse(
|
||||
req.body,
|
||||
);
|
||||
const { id, subject, body, type, style } = TemplateSchemas.update.parse(req.body);
|
||||
|
||||
let template = await TemplateService.id(id);
|
||||
|
||||
@@ -238,9 +218,7 @@ export class Templates {
|
||||
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.",
|
||||
);
|
||||
throw new NotAllowed("This template is being used by an action. Unlink the action before deleting the template.");
|
||||
}
|
||||
|
||||
await prisma.template.delete({ where: { id } });
|
||||
|
||||
@@ -1,21 +1,11 @@
|
||||
import {
|
||||
ChildControllers,
|
||||
Controller,
|
||||
Middleware,
|
||||
Post,
|
||||
} from "@overnightjs/core";
|
||||
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 { type IKey, type ISecret, isValidKey, isValidSecretKey } from "../../middleware/auth";
|
||||
import { ActionService } from "../../services/ActionService";
|
||||
import { ContactService } from "../../services/ContactService";
|
||||
import { EmailService } from "../../services/EmailService";
|
||||
@@ -30,13 +20,7 @@ import { Events } from "./Events";
|
||||
import { Templates } from "./Templates";
|
||||
|
||||
@Controller("v1")
|
||||
@ChildControllers([
|
||||
new Actions(),
|
||||
new Templates(),
|
||||
new Campaigns(),
|
||||
new Contacts(),
|
||||
new Events(),
|
||||
])
|
||||
@ChildControllers([new Actions(), new Templates(), new Campaigns(), new Contacts(), new Events()])
|
||||
export class V1 {
|
||||
@Post()
|
||||
@Post("track")
|
||||
@@ -53,14 +37,9 @@ export class V1 {
|
||||
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)}`,
|
||||
);
|
||||
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].unionErrors[0].errors[0].message);
|
||||
}
|
||||
|
||||
throw new HttpException(400, result.error.issues[0].message);
|
||||
@@ -78,10 +57,7 @@ export class V1 {
|
||||
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.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));
|
||||
@@ -139,9 +115,7 @@ export class V1 {
|
||||
|
||||
void ActionService.trigger({ event, contact, project });
|
||||
|
||||
signale.success(
|
||||
`${project.name} triggered ${event.name} for ${contact.email}`,
|
||||
);
|
||||
signale.success(`${project.name} triggered ${event.name} for ${contact.email}`);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
@@ -166,30 +140,20 @@ export class V1 {
|
||||
|
||||
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].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;
|
||||
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",
|
||||
);
|
||||
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",
|
||||
);
|
||||
throw new HttpException(401, "Custom from address must be from a verified domain");
|
||||
}
|
||||
|
||||
const emails: {
|
||||
@@ -231,16 +195,15 @@ export class V1 {
|
||||
}
|
||||
}
|
||||
|
||||
const { subject: enrichedSubject, body: enrichedBody } =
|
||||
EmailService.format({
|
||||
subject,
|
||||
body,
|
||||
data: {
|
||||
plunk_id: contact.id,
|
||||
plunk_email: contact.email,
|
||||
...JSON.parse(contact.data ?? "{}"),
|
||||
},
|
||||
});
|
||||
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: {
|
||||
@@ -287,12 +250,8 @@ export class V1 {
|
||||
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(", ")}`,
|
||||
);
|
||||
signale.success(`${project.name} sent a transactional email to ${to.join(", ")}`);
|
||||
|
||||
return res
|
||||
.status(200)
|
||||
.json({ success: true, emails, timestamp: dayjs().toISOString() });
|
||||
return res.status(200).json({ success: true, emails, timestamp: dayjs().toISOString() });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,11 +25,7 @@ export interface IKey {
|
||||
* @param res
|
||||
* @param next
|
||||
*/
|
||||
export const isAuthenticated = (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) => {
|
||||
export const isAuthenticated = (req: Request, res: Response, next: NextFunction) => {
|
||||
res.locals.auth = { type: "jwt", userId: parseJwt(req) };
|
||||
|
||||
next();
|
||||
@@ -41,11 +37,7 @@ export const isAuthenticated = (
|
||||
* @param res
|
||||
* @param next
|
||||
*/
|
||||
export const isValidSecretKey = (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) => {
|
||||
export const isValidSecretKey = (req: Request, res: Response, next: NextFunction) => {
|
||||
res.locals.auth = { type: "secret", sk: parseBearer(req, "secret") };
|
||||
|
||||
next();
|
||||
@@ -118,10 +110,7 @@ export function parseJwt(request: Request): string {
|
||||
* @param request The express request object
|
||||
* @param type
|
||||
*/
|
||||
export function parseBearer(
|
||||
request: Request,
|
||||
type?: "secret" | "public",
|
||||
): string {
|
||||
export function parseBearer(request: Request, type?: "secret" | "public"): string {
|
||||
const bearer: string | undefined = request.headers.authorization;
|
||||
|
||||
if (!bearer) {
|
||||
@@ -135,17 +124,11 @@ export function parseBearer(
|
||||
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_...",
|
||||
);
|
||||
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_",
|
||||
);
|
||||
throw new HttpException(401, "Your API key could not be parsed. API keys start with sk_ or pk_");
|
||||
}
|
||||
|
||||
if (!type) {
|
||||
@@ -153,10 +136,7 @@ export function parseBearer(
|
||||
}
|
||||
|
||||
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",
|
||||
);
|
||||
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_")) {
|
||||
|
||||
@@ -54,11 +54,9 @@ export class ActionService {
|
||||
*/
|
||||
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 },
|
||||
});
|
||||
return prisma.event.findUniqueOrThrow({ where: { id: eventId } }).actions({
|
||||
include: { events: true, template: true, notevents: true },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -68,52 +66,35 @@ export class ActionService {
|
||||
* @param event
|
||||
* @param project
|
||||
*/
|
||||
public static async trigger({
|
||||
event,
|
||||
contact,
|
||||
project,
|
||||
}: { event: Event; contact: Contact; project: 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,
|
||||
);
|
||||
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))
|
||||
) {
|
||||
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];
|
||||
const lastActionTrigger = triggers.filter((t) => t.contactId === contact.id && t.actionId === action.id)[0];
|
||||
|
||||
triggeredEvents = triggeredEvents.filter(
|
||||
(e) => e.createdAt > lastActionTrigger.createdAt,
|
||||
);
|
||||
triggeredEvents = triggeredEvents.filter((e) => e.createdAt > lastActionTrigger.createdAt);
|
||||
}
|
||||
|
||||
const updatedTriggers = [
|
||||
...new Set(triggeredEvents.map((t) => t.eventId)),
|
||||
];
|
||||
const updatedTriggers = [...new Set(triggeredEvents.map((t) => t.eventId))];
|
||||
const requiredTriggers = action.events.map((e) => e.id);
|
||||
|
||||
if (
|
||||
updatedTriggers.sort().join(",") !== requiredTriggers.sort().join(",")
|
||||
) {
|
||||
if (updatedTriggers.sort().join(",") !== requiredTriggers.sort().join(",")) {
|
||||
// Not all required events have been triggered
|
||||
continue;
|
||||
}
|
||||
@@ -140,10 +121,7 @@ export class ActionService {
|
||||
const { messageId } = await EmailService.send({
|
||||
from: {
|
||||
name: project.from ?? project.name,
|
||||
email:
|
||||
project.verified && project.email
|
||||
? project.email
|
||||
: "no-reply@useplunk.dev",
|
||||
email: project.verified && project.email ? project.email : "no-reply@useplunk.dev",
|
||||
},
|
||||
to: [contact.email],
|
||||
content: {
|
||||
|
||||
@@ -467,8 +467,8 @@ ${
|
||||
<mj-section>
|
||||
<mj-column>
|
||||
${
|
||||
footer.unsubscribe
|
||||
? `
|
||||
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;">
|
||||
@@ -476,8 +476,8 @@ ${
|
||||
</p>
|
||||
</mj-text>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
: ""
|
||||
}
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
</mj-body>
|
||||
@@ -485,22 +485,14 @@ ${
|
||||
).html.replace(/^\s+|\s+$/g, "");
|
||||
}
|
||||
|
||||
public static format({
|
||||
subject,
|
||||
body,
|
||||
data,
|
||||
}: { subject: string; body: string; data: Record<string, string> }) {
|
||||
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());
|
||||
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());
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -6,54 +6,45 @@ 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;
|
||||
}
|
||||
return wrapRedis(Keys.ProjectMembership.isMember(projectId, userId), async () => {
|
||||
if (NODE_ENV === "development") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const membership = await prisma.projectMembership.findFirst({
|
||||
where: { projectId, userId },
|
||||
});
|
||||
const membership = await prisma.projectMembership.findFirst({
|
||||
where: { projectId, userId },
|
||||
});
|
||||
|
||||
return !!membership;
|
||||
},
|
||||
);
|
||||
return !!membership;
|
||||
});
|
||||
}
|
||||
|
||||
public static async isAdmin(projectId: string, userId: string) {
|
||||
return wrapRedis(
|
||||
Keys.ProjectMembership.isAdmin(projectId, userId),
|
||||
async () => {
|
||||
if (NODE_ENV === "development") {
|
||||
return true;
|
||||
}
|
||||
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"] } },
|
||||
});
|
||||
const membership = await prisma.projectMembership.findFirst({
|
||||
where: { projectId, userId, role: { in: ["ADMIN", "OWNER"] } },
|
||||
});
|
||||
|
||||
return !!membership;
|
||||
},
|
||||
);
|
||||
return !!membership;
|
||||
});
|
||||
}
|
||||
|
||||
public static async isOwner(projectId: string, userId: string) {
|
||||
return wrapRedis(
|
||||
Keys.ProjectMembership.isOwner(projectId, userId),
|
||||
async () => {
|
||||
if (NODE_ENV === "development") {
|
||||
return true;
|
||||
}
|
||||
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" },
|
||||
});
|
||||
const membership = await prisma.projectMembership.findFirst({
|
||||
where: { projectId, userId, role: "OWNER" },
|
||||
});
|
||||
|
||||
return !!membership;
|
||||
},
|
||||
);
|
||||
return !!membership;
|
||||
});
|
||||
}
|
||||
|
||||
public static async kick(projectId: string, userId: string) {
|
||||
|
||||
@@ -51,11 +51,7 @@ export class ProjectService {
|
||||
return wrapRedis(Keys.Project.emails(id), async () => {
|
||||
return prisma.email.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ action: { projectId: id } },
|
||||
{ campaign: { projectId: id } },
|
||||
{ projectId: id },
|
||||
],
|
||||
OR: [{ action: { projectId: id } }, { campaign: { projectId: id } }, { projectId: id }],
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
@@ -107,9 +103,7 @@ export class ProjectService {
|
||||
|
||||
public static memberships(id: string) {
|
||||
return wrapRedis(Keys.Project.memberships(id), async () => {
|
||||
const memberships = await prisma.project
|
||||
.findUnique({ where: { id } })
|
||||
.memberships({ include: { user: true } });
|
||||
const memberships = await prisma.project.findUnique({ where: { id } }).memberships({ include: { user: true } });
|
||||
|
||||
if (!memberships) {
|
||||
return [];
|
||||
@@ -127,31 +121,23 @@ export class ProjectService {
|
||||
|
||||
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,
|
||||
},
|
||||
const contacts = await prisma.project.findUnique({ where: { id } }).contacts({
|
||||
where: {
|
||||
data: {
|
||||
not: null,
|
||||
},
|
||||
distinct: ["data"],
|
||||
select: {
|
||||
data: true,
|
||||
},
|
||||
});
|
||||
},
|
||||
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))),
|
||||
),
|
||||
];
|
||||
return [...new Set(contacts.filter((c) => c.data).flatMap((c) => Object.keys(JSON.parse(c.data as string))))];
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { SES } from "@aws-sdk/client-ses";
|
||||
import {
|
||||
AWS_ACCESS_KEY_ID,
|
||||
AWS_REGION,
|
||||
AWS_SECRET_ACCESS_KEY,
|
||||
} from "../app/constants";
|
||||
import { AWS_ACCESS_KEY_ID, AWS_REGION, AWS_SECRET_ACCESS_KEY } from "../app/constants";
|
||||
|
||||
export const ses = new SES({
|
||||
apiVersion: "2010-12-01",
|
||||
|
||||
Reference in New Issue
Block a user