multi tenancy stuff
This commit is contained in:
@ -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}`,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
@ -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}` },
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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 = {
|
||||
|
@ -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"*/ },
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
|
@ -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(),
|
||||
|
@ -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>");
|
||||
|
Reference in New Issue
Block a user