Initial Commit

This commit is contained in:
Dries Augustyns
2024-07-23 13:49:48 +02:00
commit 91a8840c3c
191 changed files with 36022 additions and 0 deletions
+185
View File
@@ -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(),
},
});
}
}
}
}
+18
View File
@@ -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();
}
}
+531
View File
@@ -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");
}
}
+25
View File
@@ -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));
}
}
+468
View File
@@ -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();
});
}
}
+61
View File
@@ -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;
}
}
+140
View File
@@ -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}`;
},
},
};
+27
View File
@@ -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;
}