Initial Commit
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user