multi tenancy stuff

This commit is contained in:
m5r
2021-08-06 01:07:15 +08:00
parent b54f9ef43c
commit d20eeb0617
51 changed files with 907 additions and 2542 deletions

View File

@ -5,21 +5,27 @@ import db from "../../../../db";
import insertMessagesQueue from "./insert-messages";
type Payload = {
customerId: string;
organizationId: string;
phoneNumberId: string;
};
const fetchMessagesQueue = Queue<Payload>("api/queue/fetch-messages", async ({ customerId }) => {
const [customer, phoneNumber] = await Promise.all([
db.customer.findFirst({ where: { id: customerId } }),
db.phoneNumber.findFirst({ where: { customerId } }),
]);
if (!customer || !customer.accountSid || !customer.authToken || !phoneNumber) {
const fetchMessagesQueue = Queue<Payload>("api/queue/fetch-messages", async ({ organizationId, phoneNumberId }) => {
const phoneNumber = await db.phoneNumber.findFirst({
where: { id: phoneNumberId, organizationId },
include: { organization: true },
});
if (!phoneNumber) {
return;
}
const organization = phoneNumber.organization;
if (!organization.twilioAccountSid || !organization.twilioAuthToken) {
return;
}
const [sent, received] = await Promise.all([
twilio(customer.accountSid, customer.authToken).messages.list({ from: phoneNumber.phoneNumber }),
twilio(customer.accountSid, customer.authToken).messages.list({ to: phoneNumber.phoneNumber }),
twilio(organization.twilioAccountSid, organization.twilioAuthToken).messages.list({ from: phoneNumber.number }),
twilio(organization.twilioAccountSid, organization.twilioAuthToken).messages.list({ to: phoneNumber.number }),
]);
const messagesSent = sent.filter((message) => message.direction.startsWith("outbound"));
const messagesReceived = received.filter((message) => message.direction === "inbound");
@ -29,11 +35,12 @@ const fetchMessagesQueue = Queue<Payload>("api/queue/fetch-messages", async ({ c
await insertMessagesQueue.enqueue(
{
customerId,
organizationId,
phoneNumberId,
messages,
},
{
id: `insert-messages-${customerId}`,
id: `insert-messages-${organizationId}-${phoneNumberId}`,
},
);
});

View File

@ -4,46 +4,49 @@ import twilio from "twilio";
import db, { Direction, MessageStatus } from "../../../../db";
import { encrypt } from "../../../../db/_encryption";
import notifyIncomingMessageQueue from "./notify-incoming-message";
type Payload = {
customerId: string;
organizationId: string;
phoneNumberId: string;
messageSid: MessageInstance["sid"];
};
const insertIncomingMessageQueue = Queue<Payload>(
"api/queue/insert-incoming-message",
async ({ messageSid, customerId }) => {
const customer = await db.customer.findFirst({ where: { id: customerId } });
if (!customer || !customer.accountSid || !customer.authToken) {
async ({ messageSid, organizationId, phoneNumberId }) => {
const organization = await db.organization.findFirst({
where: { id: organizationId },
});
if (!organization || !organization.twilioAccountSid || !organization.twilioAuthToken) {
return;
}
const encryptionKey = customer.encryptionKey;
const message = await twilio(customer.accountSid, customer.authToken).messages.get(messageSid).fetch();
const message = await twilio(organization.twilioAccountSid, organization.twilioAuthToken)
.messages.get(messageSid)
.fetch();
await db.message.create({
data: {
customerId,
organizationId,
phoneNumberId,
id: messageSid,
to: message.to,
from: message.from,
status: translateStatus(message.status),
direction: translateDirection(message.direction),
sentAt: message.dateCreated,
content: encrypt(message.body, customer.encryptionKey),
content: encrypt(message.body, organization.encryptionKey),
},
});
await db.message.createMany({
data: {
customerId,
content: encrypt(message.body, encryptionKey),
from: message.from,
to: message.to,
status: translateStatus(message.status),
direction: translateDirection(message.direction),
twilioSid: message.sid,
sentAt: new Date(message.dateCreated),
await notifyIncomingMessageQueue.enqueue(
{
messageSid,
organizationId,
phoneNumberId,
},
});
{ id: `notify-${messageSid}-${organizationId}-${phoneNumberId}` },
);
},
);

View File

@ -5,31 +5,39 @@ import db, { Direction, Message, MessageStatus } from "../../../../db";
import { encrypt } from "../../../../db/_encryption";
type Payload = {
customerId: string;
organizationId: string;
phoneNumberId: string;
messages: MessageInstance[];
};
const insertMessagesQueue = Queue<Payload>("api/queue/insert-messages", async ({ messages, customerId }) => {
const customer = await db.customer.findFirst({ where: { id: customerId } });
if (!customer) {
return;
}
const insertMessagesQueue = Queue<Payload>(
"api/queue/insert-messages",
async ({ messages, organizationId, phoneNumberId }) => {
const phoneNumber = await db.phoneNumber.findFirst({
where: { id: phoneNumberId, organizationId },
include: { organization: true },
});
if (!phoneNumber) {
return;
}
const sms = messages
.map<Omit<Message, "id">>((message) => ({
customerId,
content: encrypt(message.body, customer.encryptionKey),
from: message.from,
to: message.to,
status: translateStatus(message.status),
direction: translateDirection(message.direction),
twilioSid: message.sid,
sentAt: new Date(message.dateCreated),
}))
.sort((a, b) => a.sentAt.getTime() - b.sentAt.getTime());
const sms = messages
.map<Omit<Message, "id">>((message) => ({
organizationId,
phoneNumberId: phoneNumber.id,
content: encrypt(message.body, phoneNumber.organization.encryptionKey),
from: message.from,
to: message.to,
status: translateStatus(message.status),
direction: translateDirection(message.direction),
twilioSid: message.sid,
sentAt: new Date(message.dateCreated),
}))
.sort((a, b) => a.sentAt.getTime() - b.sentAt.getTime());
await db.message.createMany({ data: sms });
});
await db.message.createMany({ data: sms });
},
);
export default insertMessagesQueue;

View File

@ -11,27 +11,32 @@ const { serverRuntimeConfig, publicRuntimeConfig } = getConfig();
const logger = appLogger.child({ queue: "notify-incoming-message" });
type Payload = {
customerId: string;
organizationId: string;
phoneNumberId: string;
messageSid: MessageInstance["sid"];
};
const notifyIncomingMessageQueue = Queue<Payload>(
"api/queue/notify-incoming-message",
async ({ messageSid, customerId }) => {
async ({ messageSid, organizationId, phoneNumberId }) => {
webpush.setVapidDetails(
"mailto:mokht@rmi.al",
publicRuntimeConfig.webPush.publicKey,
serverRuntimeConfig.webPush.privateKey,
);
const customer = await db.customer.findFirst({ where: { id: customerId } });
if (!customer || !customer.accountSid || !customer.authToken) {
const organization = await db.organization.findFirst({
where: { id: organizationId },
});
if (!organization || !organization.twilioAccountSid || !organization.twilioAuthToken) {
return;
}
const message = await twilio(customer.accountSid, customer.authToken).messages.get(messageSid).fetch();
const message = await twilio(organization.twilioAccountSid, organization.twilioAuthToken)
.messages.get(messageSid)
.fetch();
const notification = { message: `${message.from} - ${message.body}` };
const subscriptions = await db.notificationSubscription.findMany({ where: { customerId: customer.id } });
const subscriptions = await db.notificationSubscription.findMany({ where: { organizationId, phoneNumberId } });
await Promise.all(
subscriptions.map(async (subscription) => {
const webPushSubscription: PushSubscription = {

View File

@ -1,40 +1,46 @@
import { Queue } from "quirrel/blitz";
import twilio from "twilio";
import db from "../../../../db";
import db, { MessageStatus } from "../../../../db";
type Payload = {
id: string;
customerId: string;
organizationId: string;
phoneNumberId: string;
to: string;
content: string;
};
const sendMessageQueue = Queue<Payload>(
"api/queue/send-message",
async ({ id, customerId, to, content }) => {
const [customer, phoneNumber] = await Promise.all([
db.customer.findFirst({ where: { id: customerId } }),
db.phoneNumber.findFirst({ where: { customerId } }),
]);
if (!customer || !customer.accountSid || !customer.authToken || !phoneNumber) {
async ({ id, organizationId, phoneNumberId, to, content }) => {
const organization = await db.organization.findFirst({
where: { id: organizationId },
include: { phoneNumbers: true },
});
const phoneNumber = organization?.phoneNumbers.find((phoneNumber) => phoneNumber.id === phoneNumberId);
if (!organization || !organization.twilioAccountSid || !organization.twilioAuthToken || !phoneNumber) {
return;
}
try {
const message = await twilio(customer.accountSid, customer.authToken).messages.create({
const message = await twilio(organization.twilioAccountSid, organization.twilioAuthToken).messages.create({
body: content,
to,
from: phoneNumber.phoneNumber,
from: phoneNumber.number,
});
await db.message.update({
where: { id },
data: { twilioSid: message.sid },
where: { organizationId_phoneNumberId_id: { id, organizationId, phoneNumberId } },
data: { id: message.sid },
});
} catch (error) {
// TODO: handle twilio error
console.log(error.code); // 21211
console.log(error.moreInfo); // https://www.twilio.com/docs/errors/21211
await db.message.update({
where: { id },
data: { status: MessageStatus.Error /*errorMessage: "Reason: failed because of"*/ },
});
}
},
{

View File

@ -3,31 +3,26 @@ import twilio from "twilio";
import db from "db";
import handler from "./incoming-message";
import notifyIncomingMessageQueue from "../queue/notify-incoming-message";
import insertIncomingMessageQueue from "../queue/insert-incoming-message";
describe("/api/webhook/incoming-message", () => {
const mockedFindFirstPhoneNumber = db.phoneNumber.findFirst as jest.Mock<
ReturnType<typeof db.phoneNumber.findFirst>
>;
const mockedFindFirstCustomer = db.customer.findFirst as jest.Mock<ReturnType<typeof db.customer.findFirst>>;
const mockedEnqueueNotifyIncomingMessage = notifyIncomingMessageQueue.enqueue as jest.Mock<
ReturnType<typeof notifyIncomingMessageQueue.enqueue>
>;
const mockedFindManyPhoneNumbers = db.phoneNumber.findMany as jest.Mock<ReturnType<typeof db.phoneNumber.findMany>>;
const mockedEnqueueInsertIncomingMessage = insertIncomingMessageQueue.enqueue as jest.Mock<
ReturnType<typeof insertIncomingMessageQueue.enqueue>
>;
const mockedValidateRequest = twilio.validateRequest as jest.Mock<ReturnType<typeof twilio.validateRequest>>;
beforeEach(() => {
mockedFindFirstPhoneNumber.mockResolvedValue({ phoneNumber: "+33757592025" } as any);
mockedFindFirstCustomer.mockResolvedValue({ id: "9292", authToken: "twi" } as any);
mockedFindManyPhoneNumbers.mockResolvedValue([
{
id: "9292",
organization: { id: "2929", twilioAuthToken: "twi" },
} as any,
]);
});
afterEach(() => {
mockedFindFirstPhoneNumber.mockReset();
mockedFindFirstCustomer.mockReset();
mockedEnqueueNotifyIncomingMessage.mockReset();
mockedFindManyPhoneNumbers.mockReset();
mockedEnqueueInsertIncomingMessage.mockReset();
mockedValidateRequest.mockReset();
});
@ -50,16 +45,15 @@ describe("/api/webhook/incoming-message", () => {
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toBe("text/html");
[mockedEnqueueNotifyIncomingMessage, mockedEnqueueNotifyIncomingMessage].forEach((enqueue) => {
expect(enqueue).toHaveBeenCalledTimes(1);
expect(enqueue).toHaveBeenCalledWith(
{
messageSid: "SM157246f02006b80953e8c753fb68fad6",
customerId: "9292",
},
{ id: "notify-SM157246f02006b80953e8c753fb68fad6" },
);
});
expect(mockedEnqueueInsertIncomingMessage).toHaveBeenCalledTimes(1);
expect(mockedEnqueueInsertIncomingMessage).toHaveBeenCalledWith(
{
messageSid: "SM157246f02006b80953e8c753fb68fad6",
phoneNumberId: "9292",
organizationId: "2929",
},
{ id: "insert-SM157246f02006b80953e8c753fb68fad6-2929-9292" },
);
},
});
});
@ -107,11 +101,7 @@ describe("/api/webhook/incoming-message", () => {
});
jest.mock("db", () => ({
phoneNumber: { findFirst: jest.fn() },
customer: { findFirst: jest.fn() },
}));
jest.mock("../queue/notify-incoming-message", () => ({
enqueue: jest.fn(),
phoneNumber: { findMany: jest.fn() },
}));
jest.mock("../queue/insert-incoming-message", () => ({
enqueue: jest.fn(),

View File

@ -40,26 +40,25 @@ export default async function incomingMessageHandler(req: BlitzApiRequest, res:
const body: Body = req.body;
try {
const customerPhoneNumber = await db.phoneNumber.findFirst({
where: { phoneNumber: body.To },
const phoneNumbers = await db.phoneNumber.findMany({
where: { number: body.To },
include: { organization: true },
});
if (!customerPhoneNumber) {
// phone number is not registered by any of our customer
res.status(500).end();
return;
}
const customer = await db.customer.findFirst({
where: { id: customerPhoneNumber.customerId },
});
if (!customer || !customer.authToken) {
if (phoneNumbers.length === 0) {
// phone number is not registered by any organization
res.status(500).end();
return;
}
const url = `https://${serverRuntimeConfig.app.baseUrl}/api/webhook/incoming-message`;
const isRequestValid = twilio.validateRequest(customer.authToken, twilioSignature, url, req.body);
if (!isRequestValid) {
const phoneNumber = phoneNumbers.find((phoneNumber) => {
// if multiple organizations have the same number
// find the organization currently using that phone number
// maybe we shouldn't let multiple organizations use the same phone number
const authToken = phoneNumber.organization.twilioAuthToken ?? "";
return twilio.validateRequest(authToken, twilioSignature, url, req.body);
});
if (!phoneNumber) {
const statusCode = 400;
const apiError: ApiError = {
statusCode,
@ -72,23 +71,16 @@ export default async function incomingMessageHandler(req: BlitzApiRequest, res:
}
const messageSid = body.MessageSid;
const customerId = customer.id;
await Promise.all([
notifyIncomingMessageQueue.enqueue(
{
messageSid,
customerId,
},
{ id: `notify-${messageSid}` },
),
insertIncomingMessageQueue.enqueue(
{
messageSid,
customerId,
},
{ id: `insert-${messageSid}` },
),
]);
const organizationId = phoneNumber.organization.id;
const phoneNumberId = phoneNumber.id;
await insertIncomingMessageQueue.enqueue(
{
messageSid,
organizationId,
phoneNumberId,
},
{ id: `insert-${messageSid}-${organizationId}-${phoneNumberId}` },
);
res.setHeader("content-type", "text/html");
res.status(200).send("<Response></Response>");

View File

@ -1,14 +1,14 @@
import type { FunctionComponent } from "react";
import { useMutation, useQuery } from "blitz";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faPaperPlane } from "@fortawesome/pro-regular-svg-icons";
import { useForm } from "react-hook-form";
import { useMutation, useQuery } from "blitz";
import sendMessage from "../mutations/send-message";
import { Direction, Message, MessageStatus } from "../../../db";
import getConversationsQuery from "../queries/get-conversations";
import useCurrentCustomer from "../../core/hooks/use-current-customer";
import useCustomerPhoneNumber from "../../core/hooks/use-customer-phone-number";
import { FunctionComponent } from "react";
import useCurrentUser from "../../core/hooks/use-current-user";
import useCurrentPhoneNumber from "../../core/hooks/use-current-phone-number";
type Form = {
content: string;
@ -20,8 +20,8 @@ type Props = {
};
const NewMessageArea: FunctionComponent<Props> = ({ recipient, onSend }) => {
const { customer } = useCurrentCustomer();
const phoneNumber = useCustomerPhoneNumber();
const { organization } = useCurrentUser();
const phoneNumber = useCurrentPhoneNumber();
const sendMessageMutation = useMutation(sendMessage)[0];
const { setQueryData: setConversationsQueryData, refetch: refetchConversations } = useQuery(
getConversationsQuery,
@ -45,9 +45,9 @@ const NewMessageArea: FunctionComponent<Props> = ({ recipient, onSend }) => {
const id = uuidv4();
const message: Message = {
id,
customerId: customer!.id,
twilioSid: id,
from: phoneNumber!.phoneNumber,
organizationId: organization!.id,
phoneNumberId: phoneNumber!.id,
from: phoneNumber!.number,
to: recipient,
content: content,
direction: Direction.Outbound,
@ -63,7 +63,12 @@ const NewMessageArea: FunctionComponent<Props> = ({ recipient, onSend }) => {
}
nextConversations[recipient] = [...nextConversations[recipient]!, message];
return nextConversations;
return Object.fromEntries(
Object.entries(nextConversations).sort(
([, a], [, b]) => b[b.length - 1]!.sentAt.getTime() - a[a.length - 1]!.sentAt.getTime(),
),
);
},
{ refetch: false },
);

View File

@ -1,10 +1,8 @@
import { resolver } from "blitz";
import { NotFoundError, resolver } from "blitz";
import { z } from "zod";
import twilio from "twilio";
import db, { Direction, MessageStatus } from "../../../db";
import getCurrentCustomer from "../../customers/queries/get-current-customer";
import getCustomerPhoneNumber from "../../phone-numbers/queries/get-customer-phone-number";
import { encrypt } from "../../../db/_encryption";
import sendMessageQueue from "../../messages/api/queue/send-message";
import appLogger from "../../../integrations/logger";
@ -17,32 +15,40 @@ const Body = z.object({
});
export default resolver.pipe(resolver.zod(Body), resolver.authorize(), async ({ content, to }, context) => {
const customer = await getCurrentCustomer(null, context);
if (!customer || !customer.accountSid || !customer.authToken) {
const organizationId = context.session.orgId;
const organization = await db.organization.findFirst({
where: { id: organizationId },
include: { phoneNumbers: true },
});
if (!organization) {
throw new NotFoundError();
}
if (!organization.twilioAccountSid || !organization.twilioAuthToken) {
return;
}
try {
await twilio(customer.accountSid, customer.authToken).lookups.v1.phoneNumbers(to).fetch();
await twilio(organization.twilioAccountSid, organization.twilioAuthToken).lookups.v1.phoneNumbers(to).fetch();
} catch (error) {
logger.error(error);
return;
}
const customerId = customer.id;
const customerPhoneNumber = await getCustomerPhoneNumber({ customerId }, context);
if (!customerPhoneNumber) {
const phoneNumber = organization.phoneNumbers[0];
if (!phoneNumber) {
return;
}
const phoneNumberId = phoneNumber.id;
const message = await db.message.create({
data: {
customerId,
organizationId,
phoneNumberId,
to,
from: customerPhoneNumber.phoneNumber,
from: phoneNumber.number,
direction: Direction.Outbound,
status: MessageStatus.Queued,
content: encrypt(content, customer.encryptionKey),
content: encrypt(content, organization.encryptionKey),
sentAt: new Date(),
},
});
@ -50,12 +56,13 @@ export default resolver.pipe(resolver.zod(Body), resolver.authorize(), async ({
await sendMessageQueue.enqueue(
{
id: message.id,
customerId,
organizationId,
phoneNumberId,
to,
content,
},
{
id: `insert-${message.id}`,
id: `insert-${message.id}-${organizationId}-${phoneNumberId}`,
},
);
});

View File

@ -3,15 +3,14 @@ import { z } from "zod";
import db, { Prisma } from "../../../db";
import { decrypt } from "../../../db/_encryption";
import getCurrentCustomer from "../../customers/queries/get-current-customer";
const GetConversations = z.object({
recipient: z.string(),
});
export default resolver.pipe(resolver.zod(GetConversations), resolver.authorize(), async ({ recipient }, context) => {
const customer = await getCurrentCustomer(null, context);
if (!customer) {
const organization = await db.organization.findFirst({ where: { id: context.session.orgId } });
if (!organization) {
throw new NotFoundError();
}
@ -23,7 +22,7 @@ export default resolver.pipe(resolver.zod(GetConversations), resolver.authorize(
return conversation.map((message) => {
return {
...message,
content: decrypt(message.content, customer.encryptionKey),
content: decrypt(message.content, organization.encryptionKey),
};
});
});

View File

@ -1,45 +1,56 @@
import { resolver, NotFoundError } from "blitz";
import { z } from "zod";
import db, { Direction, Message, Prisma } from "../../../db";
import getCurrentCustomer from "../../customers/queries/get-current-customer";
import { decrypt } from "../../../db/_encryption";
import { enforceSuperAdminIfNotCurrentOrganization, setDefaultOrganizationId } from "../../core/utils";
export default resolver.pipe(resolver.authorize(), async (_ = null, context) => {
const customer = await getCurrentCustomer(null, context);
if (!customer) {
throw new NotFoundError();
}
const messages = await db.message.findMany({
where: { customerId: customer.id },
orderBy: { sentAt: Prisma.SortOrder.asc },
});
let conversations: Record<string, Message[]> = {};
for (const message of messages) {
let recipient: string;
if (message.direction === Direction.Outbound) {
recipient = message.to;
} else {
recipient = message.from;
export default resolver.pipe(
resolver.zod(z.object({ organizationId: z.string().optional() })),
resolver.authorize(),
setDefaultOrganizationId,
enforceSuperAdminIfNotCurrentOrganization,
async ({ organizationId }) => {
const organization = await db.organization.findFirst({
where: { id: organizationId },
include: { phoneNumbers: true },
});
if (!organization) {
throw new NotFoundError();
}
if (!conversations[recipient]) {
conversations[recipient] = [];
}
conversations[recipient]!.push({
...message,
content: decrypt(message.content, customer.encryptionKey),
const phoneNumberId = organization.phoneNumbers[0]!.id;
const messages = await db.message.findMany({
where: { organizationId, phoneNumberId },
orderBy: { sentAt: Prisma.SortOrder.asc },
});
conversations[recipient]!.sort((a, b) => a.sentAt.getTime() - b.sentAt.getTime());
}
conversations = Object.fromEntries(
Object.entries(conversations).sort(
([, a], [, b]) => b[b.length - 1]!.sentAt.getTime() - a[a.length - 1]!.sentAt.getTime(),
),
);
let conversations: Record<string, Message[]> = {};
for (const message of messages) {
let recipient: string;
if (message.direction === Direction.Outbound) {
recipient = message.to;
} else {
recipient = message.from;
}
return conversations;
});
if (!conversations[recipient]) {
conversations[recipient] = [];
}
conversations[recipient]!.push({
...message,
content: decrypt(message.content, organization.encryptionKey),
});
conversations[recipient]!.sort((a, b) => a.sentAt.getTime() - b.sentAt.getTime());
}
conversations = Object.fromEntries(
Object.entries(conversations).sort(
([, a], [, b]) => b[b.length - 1]!.sentAt.getTime() - a[a.length - 1]!.sentAt.getTime(),
),
);
return conversations;
},
);