diff --git a/app/features/messages/loaders/messages.$recipient.ts b/app/features/messages/loaders/messages.$recipient.ts index d43fd77..77cc795 100644 --- a/app/features/messages/loaders/messages.$recipient.ts +++ b/app/features/messages/loaders/messages.$recipient.ts @@ -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({ conversation }); async function getConversation(recipient: string): Promise { - 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"); diff --git a/app/features/settings/actions/phone.ts b/app/features/settings/actions/phone.ts index 6af8954..e857358 100644 --- a/app/features/settings/actions/phone.ts +++ b/app/features/settings/actions/phone.ts @@ -41,7 +41,15 @@ const action: ActionFunction = async ({ request }) => { export type SetPhoneNumberActionData = FormActionData; async function setPhoneNumber(request: Request, formData: unknown) { - const { organization } = await requireLoggedIn(request); + const { organization, twilio } = await requireLoggedIn(request); + if (!twilio) { + return badRequest({ + setPhoneNumber: { + errors: { general: "Connect your Twilio account first" }, + }, + }); + } + const validation = validate(validations.setPhoneNumber, formData); if (validation.errors) { return badRequest({ 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, + }), + ]); }), ); diff --git a/app/features/settings/components/phone/phone-number-form.tsx b/app/features/settings/components/phone/phone-number-form.tsx index e8b7c7a..12f1c93 100644 --- a/app/features/settings/components/phone/phone-number-form.tsx +++ b/app/features/settings/components/phone/phone-number-form.tsx @@ -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()?.setPhoneNumber; const availablePhoneNumbers = useLoaderData().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; diff --git a/app/features/settings/components/phone/twilio-connect.tsx b/app/features/settings/components/phone/twilio-connect.tsx index dbfb46c..e9c71eb 100644 --- a/app/features/settings/components/phone/twilio-connect.tsx +++ b/app/features/settings/components/phone/twilio-connect.tsx @@ -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 ( diff --git a/app/features/settings/loaders/phone.ts b/app/features/settings/loaders/phone.ts index 89c3891..1429d68 100644 --- a/app/features/settings/loaders/phone.ts +++ b/app/features/settings/loaders/phone.ts @@ -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 }, }); diff --git a/app/queues/fetch-messages.server.ts b/app/queues/fetch-messages.server.ts index 269101a..f4c6dbe 100644 --- a/app/queues/fetch-messages.server.ts +++ b/app/queues/fetch-messages.server.ts @@ -12,24 +12,14 @@ export default Queue("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 }), diff --git a/app/queues/fetch-phone-calls.server.ts b/app/queues/fetch-phone-calls.server.ts index bf5ec40..28c8e22 100644 --- a/app/queues/fetch-phone-calls.server.ts +++ b/app/queues/fetch-phone-calls.server.ts @@ -12,24 +12,14 @@ export default Queue("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 }), diff --git a/app/queues/insert-incoming-message.server.ts b/app/queues/insert-incoming-message.server.ts index f32e0c4..1c2f7c7 100644 --- a/app/queues/insert-incoming-message.server.ts +++ b/app/queues/insert-incoming-message.server.ts @@ -16,24 +16,14 @@ export default Queue("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); diff --git a/app/queues/insert-messages.server.ts b/app/queues/insert-messages.server.ts index b7ac612..0b2129b 100644 --- a/app/queues/insert-messages.server.ts +++ b/app/queues/insert-messages.server.ts @@ -13,10 +13,7 @@ type Payload = { export default Queue("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; } diff --git a/app/queues/insert-phone-calls.server.ts b/app/queues/insert-phone-calls.server.ts index a225331..89a2287 100644 --- a/app/queues/insert-phone-calls.server.ts +++ b/app/queues/insert-phone-calls.server.ts @@ -13,10 +13,7 @@ type Payload = { export default Queue("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("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; diff --git a/app/queues/set-twilio-webhooks.server.ts b/app/queues/set-twilio-webhooks.server.ts index 319a23c..e072fe5 100644 --- a/app/queues/set-twilio-webhooks.server.ts +++ b/app/queues/set-twilio-webhooks.server.ts @@ -14,22 +14,18 @@ type Payload = { export default Queue("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); diff --git a/app/routes/__app/settings/notifications.tsx b/app/routes/__app/settings/notifications.tsx index ddd60e5..6ad6753 100644 --- a/app/routes/__app/settings/notifications.tsx +++ b/app/routes/__app/settings/notifications.tsx @@ -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, { diff --git a/app/routes/webhooks/call.ts b/app/routes/webhooks/call.ts index 19086ae..6ddf9c7 100644 --- a/app/routes/webhooks/call.ts +++ b/app/routes/webhooks/call.ts @@ -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 || diff --git a/app/routes/webhooks/message.ts b/app/routes/webhooks/message.ts index 635f08c..2764df6 100644 --- a/app/routes/webhooks/message.ts +++ b/app/routes/webhooks/message.ts @@ -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); }); diff --git a/app/utils/auth.server.ts b/app/utils/auth.server.ts index 9da56f8..86f6016 100644 --- a/app/utils/auth.server.ts +++ b/app/utils/auth.server.ts @@ -199,7 +199,7 @@ async function buildSessionData(id: string): Promise { })); 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, diff --git a/prisma/migrations/20220517184134_init/migration.sql b/prisma/migrations/20220517184134_init/migration.sql index 509536b..1c3b0fc 100644 --- a/prisma/migrations/20220517184134_init/migration.sql +++ b/prisma/migrations/20220517184134_init/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 269fde0..23037a3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 {