attach phone numbers to twilio account

This commit is contained in:
m5r 2022-06-11 02:09:37 +02:00
parent c47b57e4bf
commit 3ddd0d73ea
17 changed files with 119 additions and 128 deletions

View File

@ -5,6 +5,7 @@ import { type Message, Prisma } from "@prisma/client";
import db from "~/utils/db.server";
import { requireLoggedIn } from "~/utils/auth.server";
import { redirect } from "@remix-run/node";
type ConversationType = {
recipient: string;
@ -17,16 +18,20 @@ export type ConversationLoaderData = {
};
const loader: LoaderFunction = async ({ request, params }) => {
const { organization } = await requireLoggedIn(request);
const { twilio } = await requireLoggedIn(request);
if (!twilio) {
return redirect("/messages");
}
const twilioAccountSid = twilio.accountSid;
const recipient = decodeURIComponent(params.recipient ?? "");
const conversation = await getConversation(recipient);
return json<ConversationLoaderData>({ conversation });
async function getConversation(recipient: string): Promise<ConversationType> {
const organizationId = organization.id;
const phoneNumber = await db.phoneNumber.findUnique({
where: { organizationId_isCurrent: { organizationId, isCurrent: true } },
where: { twilioAccountSid_isCurrent: { twilioAccountSid, isCurrent: true } },
});
if (!phoneNumber || phoneNumber.isFetchingMessages) {
throw new Error("unreachable");

View File

@ -41,7 +41,15 @@ const action: ActionFunction = async ({ request }) => {
export type SetPhoneNumberActionData = FormActionData<typeof validations, "setPhoneNumber">;
async function setPhoneNumber(request: Request, formData: unknown) {
const { organization } = await requireLoggedIn(request);
const { organization, twilio } = await requireLoggedIn(request);
if (!twilio) {
return badRequest<SetPhoneNumberActionData>({
setPhoneNumber: {
errors: { general: "Connect your Twilio account first" },
},
});
}
const validation = validate(validations.setPhoneNumber, formData);
if (validation.errors) {
return badRequest<SetPhoneNumberActionData>({ setPhoneNumber: { errors: validation.errors } });
@ -49,7 +57,7 @@ async function setPhoneNumber(request: Request, formData: unknown) {
try {
await db.phoneNumber.update({
where: { organizationId_isCurrent: { organizationId: organization.id, isCurrent: true } },
where: { twilioAccountSid_isCurrent: { twilioAccountSid: twilio.accountSid, isCurrent: true } },
data: { isCurrent: false },
});
} catch (error: any) {
@ -150,7 +158,7 @@ async function setTwilioCredentials(request: Request, formData: unknown) {
await db.phoneNumber.create({
data: {
id: phoneNumberId,
organizationId: organization.id,
twilioAccountSid,
number: phoneNumber.phoneNumber,
isCurrent: false,
isFetchingCalls: true,
@ -187,7 +195,7 @@ async function setTwilioCredentials(request: Request, formData: unknown) {
}
async function refreshPhoneNumbers(request: Request) {
const { organization, twilio } = await requireLoggedIn(request);
const { twilio } = await requireLoggedIn(request);
if (!twilio) {
throw new Error("unreachable");
}
@ -205,28 +213,28 @@ async function refreshPhoneNumbers(request: Request) {
await db.phoneNumber.create({
data: {
id: phoneNumberId,
organizationId: organization.id,
twilioAccountSid: twilioAccount.accountSid,
number: phoneNumber.phoneNumber,
isCurrent: false,
isFetchingCalls: true,
isFetchingMessages: true,
},
});
await Promise.all([
fetchPhoneCallsQueue.add(`fetch calls of id=${phoneNumberId}`, {
phoneNumberId,
}),
fetchMessagesQueue.add(`fetch messages of id=${phoneNumberId}`, {
phoneNumberId,
}),
]);
} catch (error: any) {
if (error.code !== "P2002") {
// if it's not a duplicate, it's a real error we need to handle
throw error;
}
}
await Promise.all([
fetchPhoneCallsQueue.add(`fetch calls of id=${phoneNumberId}`, {
phoneNumberId,
}),
fetchMessagesQueue.add(`fetch messages of id=${phoneNumberId}`, {
phoneNumberId,
}),
]);
}),
);

View File

@ -10,13 +10,16 @@ import type { SetPhoneNumberActionData } from "~/features/settings/actions/phone
import clsx from "clsx";
export default function PhoneNumberForm() {
const { twilio } = useSession();
const { twilio, phoneNumber } = useSession();
const fetcher = useFetcher();
const transition = useTransition();
const actionData = useActionData<SetPhoneNumberActionData>()?.setPhoneNumber;
const availablePhoneNumbers = useLoaderData<PhoneSettingsLoaderData>().phoneNumbers;
const isSubmitting = transition.state === "submitting";
const actionSubmitted = transition.submission?.formData.get("_action");
const isCurrentFormTransition =
!!actionSubmitted && ["setPhoneNumber", "refreshPhoneNumbers"].includes(actionSubmitted.toString());
const isSubmitting = isCurrentFormTransition && transition.state === "submitting";
const isSuccess = actionData?.submitted === true;
const errors = actionData?.errors;
const topErrorMessage = errors?.general ?? errors?.phoneNumberSid;

View File

@ -20,7 +20,7 @@ export default function TwilioConnect() {
const topErrorMessage = actionData?.errors?.general;
const isError = typeof topErrorMessage !== "undefined";
const isCurrentFormTransition = transition.submission?.formData.get("_action") === "changePassword";
const isCurrentFormTransition = transition.submission?.formData.get("_action") === "setTwilioCredentials";
const isSubmitting = isCurrentFormTransition && transition.state === "submitting";
return (

View File

@ -20,7 +20,7 @@ const loader: LoaderFunction = async ({ request }) => {
}
const phoneNumbers = await db.phoneNumber.findMany({
where: { organizationId: organization.id },
where: { twilioAccount: { organizationId: organization.id } },
select: { id: true, number: true, isCurrent: true },
orderBy: { id: Prisma.SortOrder.desc },
});

View File

@ -12,24 +12,14 @@ export default Queue<Payload>("fetch messages", async ({ data }) => {
const { phoneNumberId } = data;
const phoneNumber = await db.phoneNumber.findUnique({
where: { id: phoneNumberId },
include: {
organization: {
select: { twilioAccount: true },
},
},
include: { twilioAccount: true },
});
if (!phoneNumber) {
logger.warn(`No phone number found with id=${phoneNumberId}`);
return;
}
const twilioAccount = phoneNumber.organization.twilioAccount;
if (!twilioAccount) {
logger.warn(`Phone number with id=${phoneNumberId} doesn't have a connected twilio account`);
return;
}
const twilioClient = getTwilioClient(twilioAccount);
const twilioClient = getTwilioClient(phoneNumber.twilioAccount);
const [sent, received] = await Promise.all([
twilioClient.messages.list({ from: phoneNumber.number }),
twilioClient.messages.list({ to: phoneNumber.number }),

View File

@ -12,24 +12,14 @@ export default Queue<Payload>("fetch phone calls", async ({ data }) => {
const { phoneNumberId } = data;
const phoneNumber = await db.phoneNumber.findUnique({
where: { id: phoneNumberId },
include: {
organization: {
select: { twilioAccount: true },
},
},
include: { twilioAccount: true },
});
if (!phoneNumber) {
logger.warn(`No phone number found with id=${phoneNumberId}`);
return;
}
const twilioAccount = phoneNumber.organization.twilioAccount;
if (!twilioAccount) {
logger.warn(`Phone number with id=${phoneNumberId} doesn't have a connected twilio account`);
return;
}
const twilioClient = getTwilioClient(twilioAccount);
const twilioClient = getTwilioClient(phoneNumber.twilioAccount);
const [callsSent, callsReceived] = await Promise.all([
twilioClient.calls.list({ from: phoneNumber.number }),
twilioClient.calls.list({ to: phoneNumber.number }),

View File

@ -16,24 +16,14 @@ export default Queue<Payload>("insert incoming message", async ({ data }) => {
logger.info(`received message ${messageSid} for ${phoneNumberId}`);
const phoneNumber = await db.phoneNumber.findUnique({
where: { id: phoneNumberId },
include: {
organization: {
select: { twilioAccount: true },
},
},
include: { twilioAccount: true },
});
if (!phoneNumber) {
logger.warn(`No phone number found with id=${phoneNumberId}`);
return;
}
const twilioAccount = phoneNumber.organization.twilioAccount;
if (!twilioAccount) {
logger.warn(`Phone number with id=${phoneNumberId} doesn't have a connected twilio account`);
return;
}
const twilioClient = getTwilioClient(twilioAccount);
const twilioClient = getTwilioClient(phoneNumber.twilioAccount);
const message = await twilioClient.messages.get(messageSid).fetch();
const status = translateMessageStatus(message.status);
const direction = translateMessageDirection(message.direction);

View File

@ -13,10 +13,7 @@ type Payload = {
export default Queue<Payload>("insert messages", async ({ data }) => {
const { messages, phoneNumberId } = data;
const phoneNumber = await db.phoneNumber.findUnique({
where: { id: phoneNumberId },
include: { organization: true },
});
const phoneNumber = await db.phoneNumber.findUnique({ where: { id: phoneNumberId } });
if (!phoneNumber) {
return;
}

View File

@ -13,10 +13,7 @@ type Payload = {
export default Queue<Payload>("insert phone calls", async ({ data }) => {
const { calls, phoneNumberId } = data;
const phoneNumber = await db.phoneNumber.findUnique({
where: { id: phoneNumberId },
include: { organization: true },
});
const phoneNumber = await db.phoneNumber.findUnique({ where: { id: phoneNumberId } });
if (!phoneNumber) {
return;
}
@ -39,8 +36,8 @@ export default Queue<Payload>("insert phone calls", async ({ data }) => {
})
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
const ddd = await db.phoneCall.createMany({ data: phoneCalls, skipDuplicates: true });
logger.info(`inserted ${ddd.count || "no"} new phone calls for phoneNumberId=${phoneNumberId}`);
const { count } = await db.phoneCall.createMany({ data: phoneCalls, skipDuplicates: true });
logger.info(`inserted ${count} new phone calls for phoneNumberId=${phoneNumberId}`);
if (!phoneNumber.isFetchingCalls) {
return;

View File

@ -14,22 +14,18 @@ type Payload = {
export default Queue<Payload>("set twilio webhooks", async ({ data }) => {
const { phoneNumberId, organizationId } = data;
const phoneNumber = await db.phoneNumber.findFirst({
where: { id: phoneNumberId, organizationId },
where: { id: phoneNumberId, twilioAccount: { organizationId } },
include: {
organization: {
select: {
twilioAccount: {
select: { accountSid: true, twimlAppSid: true, authToken: true },
},
},
twilioAccount: {
select: { accountSid: true, twimlAppSid: true, authToken: true },
},
},
});
if (!phoneNumber || !phoneNumber.organization.twilioAccount) {
if (!phoneNumber) {
return;
}
const twilioAccount = phoneNumber.organization.twilioAccount;
const twilioAccount = phoneNumber.twilioAccount;
const authToken = decrypt(twilioAccount.authToken);
const twilioClient = twilio(twilioAccount.accountSid, authToken);
const twimlApp = await getTwimlApplication(twilioClient, twilioAccount.twimlAppSid);

View File

@ -13,16 +13,20 @@ export const action: ActionFunction = async () => {
const phoneNumber = await db.phoneNumber.findUnique({
where: { id: "PN4f11f0c4155dfb5d5ac8bbab2cc23cbc" },
select: {
organization: {
select: {
memberships: {
select: { notificationSubscription: true },
twilioAccount: {
include: {
organization: {
select: {
memberships: {
select: { notificationSubscription: true },
},
},
},
},
},
},
});
const subscriptions = phoneNumber!.organization.memberships.flatMap(
const subscriptions = phoneNumber!.twilioAccount.organization.memberships.flatMap(
(membership) => membership.notificationSubscription,
);
await notify(subscriptions, {

View File

@ -22,36 +22,45 @@ export const action: ActionFunction = async ({ request }) => {
const organizationId = body.From.slice("client:".length).split("__")[0];
try {
const twilioAccount = await db.twilioAccount.findUnique({ where: { organizationId } });
if (!twilioAccount) {
// this shouldn't be happening
return new Response(null, { status: 402 });
}
const phoneNumber = await db.phoneNumber.findUnique({
where: { organizationId_isCurrent: { organizationId, isCurrent: true } },
where: { twilioAccountSid_isCurrent: { twilioAccountSid: twilioAccount.accountSid, isCurrent: true } },
include: {
organization: {
twilioAccount: {
include: {
subscriptions: {
where: {
OR: [
{ status: { not: SubscriptionStatus.deleted } },
{
status: SubscriptionStatus.deleted,
cancellationEffectiveDate: { gt: new Date() },
organization: {
select: {
subscriptions: {
where: {
OR: [
{ status: { not: SubscriptionStatus.deleted } },
{
status: SubscriptionStatus.deleted,
cancellationEffectiveDate: { gt: new Date() },
},
],
},
],
orderBy: { lastEventTime: Prisma.SortOrder.desc },
},
},
orderBy: { lastEventTime: Prisma.SortOrder.desc },
},
twilioAccount: true,
},
},
},
});
if (phoneNumber?.organization.subscriptions.length === 0) {
if (phoneNumber?.twilioAccount.organization.subscriptions.length === 0) {
// decline the outgoing call because
// the organization is on the free plan
return new Response(null, { status: 402 });
}
const encryptedAuthToken = phoneNumber?.organization.twilioAccount?.authToken;
const encryptedAuthToken = phoneNumber?.twilioAccount.authToken;
const authToken = encryptedAuthToken ? decrypt(encryptedAuthToken) : "";
if (
!phoneNumber ||

View File

@ -20,21 +20,24 @@ export const action: ActionFunction = async ({ request }) => {
const phoneNumbers = await db.phoneNumber.findMany({
where: { number: body.To },
include: {
organization: {
twilioAccount: {
include: {
subscriptions: {
where: {
OR: [
{ status: { not: SubscriptionStatus.deleted } },
{
status: SubscriptionStatus.deleted,
cancellationEffectiveDate: { gt: new Date() },
organization: {
select: {
subscriptions: {
where: {
OR: [
{ status: { not: SubscriptionStatus.deleted } },
{
status: SubscriptionStatus.deleted,
cancellationEffectiveDate: { gt: new Date() },
},
],
},
],
orderBy: { lastEventTime: Prisma.SortOrder.desc },
},
},
orderBy: { lastEventTime: Prisma.SortOrder.desc },
},
twilioAccount: true,
},
},
},
@ -45,7 +48,7 @@ export const action: ActionFunction = async ({ request }) => {
}
const phoneNumbersWithActiveSub = phoneNumbers.filter(
(phoneNumber) => phoneNumber.organization.subscriptions.length > 0,
(phoneNumber) => phoneNumber.twilioAccount.organization.subscriptions.length > 0,
);
if (phoneNumbersWithActiveSub.length === 0) {
// accept the webhook but don't store incoming message
@ -57,7 +60,7 @@ export const action: ActionFunction = async ({ request }) => {
// if multiple organizations have the same number
// find the organization currently using that phone number
// maybe we shouldn't let that happen by restricting a phone number to one org?
const encryptedAuthToken = phoneNumber.organization.twilioAccount?.authToken;
const encryptedAuthToken = phoneNumber.twilioAccount.authToken;
const authToken = encryptedAuthToken ? decrypt(encryptedAuthToken) : "";
return twilio.validateRequest(authToken, twilioSignature, smsUrl, body);
});

View File

@ -199,7 +199,7 @@ async function buildSessionData(id: string): Promise<SessionData> {
}));
const { twilioAccount, ...organization } = organizations[0];
const phoneNumber = await db.phoneNumber.findUnique({
where: { organizationId_isCurrent: { organizationId: organization.id, isCurrent: true } },
where: { twilioAccountSid_isCurrent: { twilioAccountSid: twilioAccount?.accountSid ?? "", isCurrent: true } },
});
return {
user: rest,

View File

@ -151,7 +151,7 @@ CREATE TABLE "PhoneNumber" (
"isCurrent" BOOLEAN NOT NULL,
"isFetchingMessages" BOOLEAN,
"isFetchingCalls" BOOLEAN,
"organizationId" TEXT NOT NULL,
"twilioAccountSid" TEXT NOT NULL,
CONSTRAINT "PhoneNumber_pkey" PRIMARY KEY ("id")
);
@ -195,7 +195,7 @@ CREATE INDEX "Message_phoneNumberId_recipient_idx" ON "Message"("phoneNumberId",
CREATE INDEX "PhoneCall_phoneNumberId_recipient_idx" ON "PhoneCall"("phoneNumberId", "recipient");
-- CreateIndex
CREATE UNIQUE INDEX "PhoneNumber_organizationId_isCurrent_key" ON "PhoneNumber"("organizationId", "isCurrent") WHERE ("isCurrent" = true);
CREATE UNIQUE INDEX "PhoneNumber_twilioAccountSid_isCurrent_key" ON "PhoneNumber"("twilioAccountSid", "isCurrent") WHERE ("isCurrent" = true);
-- CreateIndex
CREATE UNIQUE INDEX "NotificationSubscription_endpoint_key" ON "NotificationSubscription"("endpoint");
@ -228,7 +228,7 @@ ALTER TABLE "Message" ADD CONSTRAINT "Message_phoneNumberId_fkey" FOREIGN KEY ("
ALTER TABLE "PhoneCall" ADD CONSTRAINT "PhoneCall_phoneNumberId_fkey" FOREIGN KEY ("phoneNumberId") REFERENCES "PhoneNumber"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PhoneNumber" ADD CONSTRAINT "PhoneNumber_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "PhoneNumber" ADD CONSTRAINT "PhoneNumber_twilioAccountSid_fkey" FOREIGN KEY ("twilioAccountSid") REFERENCES "TwilioAccount"("accountSid") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "NotificationSubscription" ADD CONSTRAINT "NotificationSubscription_membershipId_fkey" FOREIGN KEY ("membershipId") REFERENCES "Membership"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -8,16 +8,16 @@ datasource db {
}
model TwilioAccount {
accountSid String @id
createdAt DateTime @default(now()) @db.Timestamptz(6)
updatedAt DateTime @updatedAt @db.Timestamptz(6)
authToken String
twimlAppSid String?
apiKeySid String?
apiKeySecret String?
organizationId String @unique
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
accountSid String @id
createdAt DateTime @default(now()) @db.Timestamptz(6)
updatedAt DateTime @updatedAt @db.Timestamptz(6)
authToken String
twimlAppSid String?
apiKeySid String?
apiKeySecret String?
organizationId String @unique
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
phoneNumbers PhoneNumber[]
}
model Organization {
@ -27,7 +27,6 @@ model Organization {
twilioAccount TwilioAccount?
memberships Membership[]
phoneNumbers PhoneNumber[]
subscriptions Subscription[] // many subscriptions to keep a history
}
@ -133,18 +132,18 @@ model PhoneCall {
}
model PhoneNumber {
id String @id
createdAt DateTime @default(now()) @db.Timestamptz(6)
id String @id
createdAt DateTime @default(now()) @db.Timestamptz(6)
number String
isCurrent Boolean
isFetchingMessages Boolean?
isFetchingCalls Boolean?
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
twilioAccountSid String
twilioAccount TwilioAccount @relation(fields: [twilioAccountSid], references: [accountSid], onDelete: Cascade)
messages Message[]
phoneCalls PhoneCall[]
@@unique([organizationId, isCurrent])
@@unique([twilioAccountSid, isCurrent])
}
model NotificationSubscription {