diff --git a/app/features/core/components/missing-twilio-credentials.tsx b/app/features/core/components/missing-twilio-credentials.tsx index ac4ba8f..5ce7f82 100644 --- a/app/features/core/components/missing-twilio-credentials.tsx +++ b/app/features/core/components/missing-twilio-credentials.tsx @@ -13,7 +13,7 @@ export default function MissingTwilioCredentials() {

- - - + ); } diff --git a/app/features/messages/components/conversations-list.tsx b/app/features/messages/components/conversations-list.tsx index 9a6d05b..f7299ae 100644 --- a/app/features/messages/components/conversations-list.tsx +++ b/app/features/messages/components/conversations-list.tsx @@ -1,4 +1,5 @@ -import { Link, useLoaderData } from "@remix-run/react"; +import { Link } from "@remix-run/react"; +import { useLoaderData } from "superjson-remix"; import { IoChevronForward } from "react-icons/io5"; import { formatRelativeDate } from "~/features/core/helpers/date-formatter"; diff --git a/app/features/phone-calls/components/phone-calls-list.tsx b/app/features/phone-calls/components/phone-calls-list.tsx index cb91790..463f2c2 100644 --- a/app/features/phone-calls/components/phone-calls-list.tsx +++ b/app/features/phone-calls/components/phone-calls-list.tsx @@ -1,4 +1,5 @@ -import { Link, useLoaderData } from "@remix-run/react"; +import { Link } from "@remix-run/react"; +import { useLoaderData } from "superjson-remix"; import { HiPhoneMissedCall, HiPhoneIncoming, HiPhoneOutgoing } from "react-icons/hi"; import clsx from "clsx"; import { Direction, CallStatus } from "@prisma/client"; diff --git a/app/features/settings/actions/phone.ts b/app/features/settings/actions/phone.ts new file mode 100644 index 0000000..0413c0b --- /dev/null +++ b/app/features/settings/actions/phone.ts @@ -0,0 +1,48 @@ +import { type ActionFunction, json } from "@remix-run/node"; +import { badRequest } from "remix-utils"; +import { z } from "zod"; + +import db from "~/utils/db.server"; +import { type FormError, validate } from "~/utils/validation.server"; +import { requireLoggedIn } from "~/utils/auth.server"; + +type SetPhoneNumberFailureActionData = { errors: FormError; submitted?: never }; +type SetPhoneNumberSuccessfulActionData = { errors?: never; submitted: true }; +export type SetPhoneNumberActionData = SetPhoneNumberFailureActionData | SetPhoneNumberSuccessfulActionData; + +const action: ActionFunction = async ({ request }) => { + const { organizations } = await requireLoggedIn(request); + const organization = organizations[0]; + const formData = Object.fromEntries(await request.formData()); + const validation = validate(bodySchema, formData); + if (validation.errors) { + return badRequest({ errors: validation.errors }); + } + + try { + await db.phoneNumber.update({ + where: { organizationId_isCurrent: { organizationId: organization.id, isCurrent: true } }, + data: { isCurrent: false }, + }); + } catch (error: any) { + if (error.code !== "P2025") { + // if any error other than record not found + throw error; + } + } + + await db.phoneNumber.update({ + where: { id: validation.data.phoneNumberSid }, + data: { isCurrent: true }, + }); + + return json({ submitted: true }); +}; + +export default action; + +const bodySchema = z.object({ + phoneNumberSid: z + .string() + .refine((phoneNumberSid) => phoneNumberSid.startsWith("PN"), "Select a valid phone number"), +}); diff --git a/app/features/settings/components/phone/help-modal.tsx b/app/features/settings/components/phone/help-modal.tsx index 8b1b972..f037ce7 100644 --- a/app/features/settings/components/phone/help-modal.tsx +++ b/app/features/settings/components/phone/help-modal.tsx @@ -14,17 +14,16 @@ const HelpModal: FunctionComponent = ({ isHelpModalOpen, closeModal }) =>
- Need help finding your Twilio credentials? + Need some help?

- You can check out our{" "} - - getting started - {" "} - guide to set up your account with your Twilio credentials. + Try{" "} + + reconnecting your Twilio account + to refresh the phone numbers.

- If you feel stuck, pick a date & time on{" "} + If you are stuck, pick a date & time on{" "} our calendly {" "} diff --git a/app/features/settings/components/phone/phone-number-form.tsx b/app/features/settings/components/phone/phone-number-form.tsx index 7c100e6..56d3d8d 100644 --- a/app/features/settings/components/phone/phone-number-form.tsx +++ b/app/features/settings/components/phone/phone-number-form.tsx @@ -1,47 +1,45 @@ -import { useActionData, useCatch, useLoaderData, useTransition } from "@remix-run/react"; +import { Form, useActionData, useCatch, useLoaderData, useTransition } from "@remix-run/react"; import Button from "../button"; import SettingsSection from "../settings-section"; import Alert from "~/features/core/components/alert"; import useSession from "~/features/core/hooks/use-session"; -import { PhoneSettingsLoaderData } from "~/routes/__app/settings/phone"; +import type { PhoneSettingsLoaderData } from "~/routes/__app/settings/phone"; +import type { SetPhoneNumberActionData } from "~/features/settings/actions/phone"; export default function PhoneNumberForm() { const transition = useTransition(); - const actionData = useActionData(); + const actionData = useActionData(); const { currentOrganization } = useSession(); + const availablePhoneNumbers = useLoaderData().phoneNumbers; const isSubmitting = transition.state === "submitting"; const isSuccess = actionData?.submitted === true; - const error = actionData?.error; - const isError = !!error; - + const errors = actionData?.errors; + const topErrorMessage = errors?.general ?? errors?.phoneNumberSid; + const isError = typeof topErrorMessage !== "undefined"; + const currentPhoneNumber = availablePhoneNumbers.find(phoneNumber => phoneNumber.isCurrent === true); const hasFilledTwilioCredentials = Boolean(currentOrganization.twilioAccountSid) - const availablePhoneNumbers = useLoaderData().phoneNumbers; - - const onSubmit = async () => { - // await setPhoneNumberMutation({ phoneNumberSid }); // TODO - }; if (!hasFilledTwilioCredentials) { return null; } return ( -

+
} > {isError ? (
- +
) : null} @@ -58,16 +56,17 @@ export default function PhoneNumberForm() { id="phoneNumberSid" name="phoneNumberSid" className="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm rounded-md" + defaultValue={currentPhoneNumber?.id} > ))} - + ); } diff --git a/app/features/settings/components/phone/twilio-connect.tsx b/app/features/settings/components/phone/twilio-connect.tsx index 870d128..fc39db5 100644 --- a/app/features/settings/components/phone/twilio-connect.tsx +++ b/app/features/settings/components/phone/twilio-connect.tsx @@ -11,12 +11,12 @@ export default function TwilioConnect() { return ( <> -
- + +
-
+
Shellphone needs to connect to your Twilio account to securely use your phone numbers.
@@ -31,8 +31,8 @@ export default function TwilioConnect() { ) : (

✓ Your Twilio account is connected to Shellphone.

)} - -
+
+ setIsHelpModalOpen(false)} isHelpModalOpen={isHelpModalOpen} /> diff --git a/app/queues/fetch-messages.server.ts b/app/queues/fetch-messages.server.ts new file mode 100644 index 0000000..5084b15 --- /dev/null +++ b/app/queues/fetch-messages.server.ts @@ -0,0 +1,36 @@ +import { Queue } from "~/utils/queue.server"; +import db from "~/utils/db.server"; +import logger from "~/utils/logger.server"; +import getTwilioClient from "~/utils/twilio.server"; +import insertMessagesQueue from "./insert-messages.server"; + +type Payload = { + phoneNumberId: string; +}; + +export default Queue("fetch messages", async ({ data }) => { + const { phoneNumberId } = data; + const phoneNumber = await db.phoneNumber.findUnique({ + where: { id: phoneNumberId }, + include: { organization: true }, + }); + if (!phoneNumber) { + logger.warn(`No phone number found with id=${phoneNumberId}`); + return; + } + + const organization = phoneNumber.organization; + const twilioClient = getTwilioClient(organization); + const [sent, received] = await Promise.all([ + twilioClient.messages.list({ from: phoneNumber.number }), + twilioClient.messages.list({ to: phoneNumber.number }), + ]); + const messagesSent = sent.filter((message) => message.direction.startsWith("outbound")); + const messagesReceived = received.filter((message) => message.direction === "inbound"); + const messages = [...messagesSent, ...messagesReceived]; + + await insertMessagesQueue.add(`insert messages of id=${phoneNumberId}`, { + phoneNumberId, + messages, + }); +}); diff --git a/app/queues/fetch-phone-calls.server.ts b/app/queues/fetch-phone-calls.server.ts new file mode 100644 index 0000000..f5ee5c4 --- /dev/null +++ b/app/queues/fetch-phone-calls.server.ts @@ -0,0 +1,34 @@ +import { Queue } from "~/utils/queue.server"; +import db from "~/utils/db.server"; +import logger from "~/utils/logger.server"; +import getTwilioClient from "~/utils/twilio.server"; +import insertCallsQueue from "./insert-phone-calls.server"; + +type Payload = { + phoneNumberId: string; +}; + +export default Queue("fetch phone calls", async ({ data }) => { + const { phoneNumberId } = data; + const phoneNumber = await db.phoneNumber.findUnique({ + where: { id: phoneNumberId }, + include: { organization: true }, + }); + if (!phoneNumber) { + logger.warn(`No phone number found with id=${phoneNumberId}`); + return; + } + + const organization = phoneNumber.organization; + const twilioClient = getTwilioClient(organization); + const [callsSent, callsReceived] = await Promise.all([ + twilioClient.calls.list({ from: phoneNumber.number }), + twilioClient.calls.list({ to: phoneNumber.number }), + ]); + const calls = [...callsSent, ...callsReceived]; + + await insertCallsQueue.add(`insert calls of id=${phoneNumberId}`, { + phoneNumberId, + calls, + }); +}); diff --git a/app/queues/index.ts b/app/queues/index.ts index 812cf34..f613a2c 100644 --- a/app/queues/index.ts +++ b/app/queues/index.ts @@ -1,3 +1,13 @@ import deleteUserDataQueue from "./delete-user-data.server"; +import fetchPhoneCallsQueue from "./fetch-phone-calls.server"; +import insertPhoneCallsQueue from "./insert-phone-calls.server"; +import fetchMessagesQueue from "./fetch-messages.server"; +import insertMessagesQueue from "./insert-messages.server"; -export default [deleteUserDataQueue]; +export default [ + deleteUserDataQueue, + fetchPhoneCallsQueue, + insertPhoneCallsQueue, + fetchMessagesQueue, + insertMessagesQueue, +]; diff --git a/app/queues/insert-messages.server.ts b/app/queues/insert-messages.server.ts new file mode 100644 index 0000000..26e87fd --- /dev/null +++ b/app/queues/insert-messages.server.ts @@ -0,0 +1,48 @@ +import type { MessageInstance } from "twilio/lib/rest/api/v2010/account/message"; +import type { Message } from "@prisma/client"; + +import { Queue } from "~/utils/queue.server"; +import db from "~/utils/db.server"; +import { translateMessageDirection, translateMessageStatus } from "~/utils/twilio.server"; +import logger from "~/utils/logger.server"; + +type Payload = { + phoneNumberId: string; + messages: MessageInstance[]; +}; + +export default Queue("insert messages", async ({ data }) => { + const { messages, phoneNumberId } = data; + const phoneNumber = await db.phoneNumber.findUnique({ + where: { id: phoneNumberId }, + include: { organization: true }, + }); + if (!phoneNumber) { + return; + } + + const sms = messages + .map((message) => ({ + id: message.sid, + phoneNumberId: phoneNumber.id, + content: message.body, + from: message.from, + to: message.to, + status: translateMessageStatus(message.status), + direction: translateMessageDirection(message.direction), + sentAt: new Date(message.dateCreated), + })) + .sort((a, b) => a.sentAt.getTime() - b.sentAt.getTime()); + + const { count } = await db.message.createMany({ data: sms, skipDuplicates: true }); + logger.info(`inserted ${count} new messages for phoneNumberId=${phoneNumberId}`) + + if (!phoneNumber.isFetchingMessages) { + return; + } + + await db.phoneNumber.update({ + where: { id: phoneNumberId }, + data: { isFetchingMessages: null }, + }); +}); diff --git a/app/queues/insert-phone-calls.server.ts b/app/queues/insert-phone-calls.server.ts new file mode 100644 index 0000000..ac693b0 --- /dev/null +++ b/app/queues/insert-phone-calls.server.ts @@ -0,0 +1,48 @@ +import type { CallInstance } from "twilio/lib/rest/api/v2010/account/call"; +import type { PhoneCall } from "@prisma/client"; + +import { Queue } from "~/utils/queue.server"; +import db from "~/utils/db.server"; +import { translateCallDirection, translateCallStatus } from "~/utils/twilio.server"; +import logger from "~/utils/logger.server"; + +type Payload = { + phoneNumberId: string; + calls: CallInstance[]; +}; + +export default Queue("insert phone calls", async ({ data }) => { + const { calls, phoneNumberId } = data; + const phoneNumber = await db.phoneNumber.findUnique({ + where: { id: phoneNumberId }, + include: { organization: true }, + }); + if (!phoneNumber) { + return; + } + + const phoneCalls = calls + .map((call) => ({ + phoneNumberId, + id: call.sid, + from: call.from, + to: call.to, + direction: translateCallDirection(call.direction), + status: translateCallStatus(call.status), + duration: call.duration, + createdAt: new Date(call.dateCreated), + })) + .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}`); + + if (!phoneNumber.isFetchingCalls) { + return; + } + + await db.phoneNumber.update({ + where: { id: phoneNumberId }, + data: { isFetchingCalls: null }, + }); +}); diff --git a/app/routes/__app/calls.tsx b/app/routes/__app/calls.tsx index 4d831e5..497ee72 100644 --- a/app/routes/__app/calls.tsx +++ b/app/routes/__app/calls.tsx @@ -1,6 +1,8 @@ import { Suspense } from "react"; import { type PhoneCall, Prisma } from "@prisma/client"; -import { type LoaderFunction, json } from "@remix-run/node"; +import { type LoaderFunction } from "@remix-run/node"; +import { json, useLoaderData } from "superjson-remix"; +import { parsePhoneNumber } from "awesome-phonenumber"; import MissingTwilioCredentials from "~/features/core/components/missing-twilio-credentials"; import PageTitle from "~/features/core/components/page-title"; @@ -9,30 +11,46 @@ import InactiveSubscription from "~/features/core/components/inactive-subscripti import PhoneCallsList from "~/features/phone-calls/components/phone-calls-list"; import { requireLoggedIn } from "~/utils/auth.server"; import db from "~/utils/db.server"; -import { parsePhoneNumber } from "awesome-phonenumber"; type PhoneCallMeta = { formattedPhoneNumber: string; country: string | "unknown"; }; -export type PhoneCallsLoaderData = { phoneCalls: (PhoneCall & { toMeta: PhoneCallMeta; fromMeta: PhoneCallMeta })[] }; +export type PhoneCallsLoaderData = { + user: { + hasOngoingSubscription: boolean; + hasPhoneNumber: boolean; + }; + phoneCalls: (PhoneCall & { toMeta: PhoneCallMeta; fromMeta: PhoneCallMeta })[] | undefined; +}; export const loader: LoaderFunction = async ({ request }) => { const { organizations } = await requireLoggedIn(request); const organizationId = organizations[0].id; - const phoneNumberId = ""; - const phoneNumber = await db.phoneNumber.findFirst({ where: { organizationId, id: phoneNumberId } }); - if (phoneNumber?.isFetchingCalls) { - return; + const phoneNumber = await db.phoneNumber.findUnique({ + where: { organizationId_isCurrent: { organizationId, isCurrent: true } }, + }); + if (!phoneNumber || phoneNumber.isFetchingCalls) { + return json({ + user: { + hasOngoingSubscription: true, // TODO + hasPhoneNumber: Boolean(phoneNumber), + }, + phoneCalls: undefined, + }); } const phoneCalls = await db.phoneCall.findMany({ - where: { phoneNumberId }, + where: { phoneNumberId: phoneNumber.id }, orderBy: { createdAt: Prisma.SortOrder.desc }, }); return json({ + user: { + hasOngoingSubscription: true, // TODO + hasPhoneNumber: Boolean(phoneNumber), + }, phoneCalls: phoneCalls.map((phoneCall) => ({ ...phoneCall, fromMeta: getPhoneNumberMeta(phoneCall.from), @@ -299,13 +317,9 @@ export const loader: LoaderFunction = async ({ request }) => { }; export default function PhoneCalls() { - const { hasFilledTwilioCredentials, hasPhoneNumber, hasOngoingSubscription } = { - hasFilledTwilioCredentials: false, - hasPhoneNumber: false, - hasOngoingSubscription: false, - }; + const { hasPhoneNumber, hasOngoingSubscription } = useLoaderData().user; - if (!hasFilledTwilioCredentials || !hasPhoneNumber) { + if (!hasPhoneNumber) { return ( <> @@ -334,10 +348,10 @@ export default function PhoneCalls() { <>
- }> - {/* TODO: skeleton phone calls list */} - - + {/*}>*/} + {/* TODO: skeleton phone calls list */} + + {/**/}
); diff --git a/app/routes/__app/messages.$recipient.tsx b/app/routes/__app/messages.$recipient.tsx index da3e5e0..5a87669 100644 --- a/app/routes/__app/messages.$recipient.tsx +++ b/app/routes/__app/messages.$recipient.tsx @@ -1,6 +1,7 @@ import { Suspense } from "react"; -import { json, type LoaderFunction, type MetaFunction } from "@remix-run/node"; -import { useLoaderData, useNavigate, useParams } from "@remix-run/react"; +import type { LoaderFunction, MetaFunction } from "@remix-run/node"; +import { useNavigate, useParams } from "@remix-run/react"; +import { json, useLoaderData } from "superjson-remix"; import { IoCall, IoChevronBack, IoInformationCircle } from "react-icons/io5"; import { type Message, Prisma } from "@prisma/client"; @@ -38,32 +39,21 @@ export const loader: LoaderFunction = async ({ request, params }) => { return json({ conversation }); async function getConversation(recipient: string): Promise { - /*if (!hasFilledTwilioCredentials) { - return; - }*/ - const organizationId = organizations[0].id; - const organization = await db.organization.findFirst({ - where: { id: organizationId }, - include: { phoneNumbers: true }, + const phoneNumber = await db.phoneNumber.findUnique({ + where: { organizationId_isCurrent: { organizationId, isCurrent: true } }, }); - if (!organization || !organization.phoneNumbers[0]) { - throw new Error("Not found"); - } - - const phoneNumber = organization.phoneNumbers[0]; // TODO: use the active number, not the first one - const phoneNumberId = phoneNumber.id; - if (organization.phoneNumbers[0].isFetchingMessages) { - throw new Error("Not found"); + if (!phoneNumber || phoneNumber.isFetchingMessages) { + throw new Error("unreachable"); } const formattedPhoneNumber = parsePhoneNumber(recipient).getNumber("international"); const messages = await db.message.findMany({ where: { - phoneNumberId, + phoneNumberId: phoneNumber.id, OR: [{ from: recipient }, { to: recipient }], }, - orderBy: { sentAt: Prisma.SortOrder.desc }, + orderBy: { sentAt: Prisma.SortOrder.asc }, }); return { recipient, @@ -91,9 +81,9 @@ export default function ConversationPage() { - Loading messages with {recipient}
}> - - + {/*Loading messages with {recipient}
}>*/} + + {/**/} ); } diff --git a/app/routes/__app/messages.tsx b/app/routes/__app/messages.tsx index 430f86f..7f90d54 100644 --- a/app/routes/__app/messages.tsx +++ b/app/routes/__app/messages.tsx @@ -1,6 +1,6 @@ -import { type LoaderFunction, json } from "@remix-run/node"; -import { useLoaderData } from "@remix-run/react"; -import { type Message, Prisma, Direction } from "@prisma/client"; +import { type LoaderFunction } from "@remix-run/node"; +import { json, useLoaderData } from "superjson-remix"; +import { type Message, Prisma, Direction, SubscriptionStatus } from "@prisma/client"; import { parsePhoneNumber } from "awesome-phonenumber"; import PageTitle from "~/features/core/components/page-title"; @@ -11,7 +11,6 @@ import { requireLoggedIn } from "~/utils/auth.server"; export type MessagesLoaderData = { user: { - hasFilledTwilioCredentials: boolean; hasPhoneNumber: boolean; }; conversations: Record | undefined; @@ -25,7 +24,7 @@ type Conversation = { export const loader: LoaderFunction = async ({ request }) => { const { id, organizations } = await requireLoggedIn(request); - /*const user = await db.user.findFirst({ + const user = await db.user.findFirst({ where: { id }, select: { id: true, @@ -54,12 +53,9 @@ export const loader: LoaderFunction = async ({ request }) => { }, }, }); - const organization = user!.memberships[0]!.organization; - const hasFilledTwilioCredentials = Boolean(organization?.twilioAccountSid && organization?.twilioAuthToken);*/ - const hasFilledTwilioCredentials = false; - const phoneNumber = await db.phoneNumber.findFirst({ - // TODO: use the active number, not the first one - where: { organizationId: organizations[0].id }, + const organization = user!.memberships[0].organization; + const phoneNumber = await db.phoneNumber.findUnique({ + where: { organizationId_isCurrent: { organizationId: organization.id, isCurrent: true } }, select: { id: true, organizationId: true, @@ -69,34 +65,21 @@ export const loader: LoaderFunction = async ({ request }) => { const conversations = await getConversations(); return json({ - user: { - hasFilledTwilioCredentials, - hasPhoneNumber: Boolean(phoneNumber), - }, + user: { hasPhoneNumber: Boolean(phoneNumber) }, conversations, }); async function getConversations() { - if (!hasFilledTwilioCredentials) { - return; - } - const organizationId = organizations[0].id; - const organization = await db.organization.findFirst({ - where: { id: organizationId }, - include: { phoneNumbers: true }, + const phoneNumber = await db.phoneNumber.findUnique({ + where: { organizationId_isCurrent: { organizationId, isCurrent: true } }, }); - if (!organization || !organization.phoneNumbers[0]) { - throw new Error("Not found"); - } - - const phoneNumberId = organization.phoneNumbers[0].id; // TODO: use the active number, not the first one - if (organization.phoneNumbers[0].isFetchingMessages) { + if (!phoneNumber || phoneNumber.isFetchingMessages) { return; } const messages = await db.message.findMany({ - where: { phoneNumberId }, + where: { phoneNumberId: phoneNumber.id }, orderBy: { sentAt: Prisma.SortOrder.desc }, }); @@ -118,7 +101,7 @@ export const loader: LoaderFunction = async ({ request }) => { }; } - if (conversations[recipient].lastMessage.sentAt > message.sentAt) { + if (message.sentAt > conversations[recipient].lastMessage.sentAt) { conversations[recipient].lastMessage = message; } /*conversations[recipient]!.messages.push({ @@ -129,13 +112,12 @@ export const loader: LoaderFunction = async ({ request }) => { return conversations; } - }; export default function MessagesPage() { const { user } = useLoaderData(); - if (!user.hasFilledTwilioCredentials || !user.hasPhoneNumber) { + if (!user.hasPhoneNumber) { return ( <> diff --git a/app/routes/__app/settings/phone.tsx b/app/routes/__app/settings/phone.tsx index c5fb202..3888b86 100644 --- a/app/routes/__app/settings/phone.tsx +++ b/app/routes/__app/settings/phone.tsx @@ -1,31 +1,36 @@ import { type LoaderFunction, json } from "@remix-run/node"; +import { type PhoneNumber, Prisma } from "@prisma/client"; +import { requireLoggedIn } from "~/utils/auth.server"; +import settingsPhoneAction from "~/features/settings/actions/phone"; import TwilioConnect from "~/features/settings/components/phone/twilio-connect"; import PhoneNumberForm from "~/features/settings/components/phone/phone-number-form"; -import { requireLoggedIn } from "~/utils/auth.server"; -import getTwilioClient from "~/utils/twilio.server"; +import logger from "~/utils/logger.server"; +import db from "~/utils/db.server"; export type PhoneSettingsLoaderData = { - phoneNumbers: { - phoneNumber: string; - sid: string; - }[]; -} + phoneNumbers: Pick[]; +}; export const loader: LoaderFunction = async ({ request }) => { const { organizations } = await requireLoggedIn(request); const organization = organizations[0]; if (!organization.twilioAccountSid) { - throw new Error("Twilio account is not connected"); + logger.warn("Twilio account is not connected"); + return json({ phoneNumbers: [] }); } - const twilioClient = getTwilioClient(organization); - const incomingPhoneNumbers = await twilioClient.incomingPhoneNumbers.list(); - const phoneNumbers = incomingPhoneNumbers.map(({ phoneNumber, sid }) => ({ phoneNumber, sid })); + const phoneNumbers = await db.phoneNumber.findMany({ + where: { organizationId: organization.id }, + select: { id: true, number: true, isCurrent: true }, + orderBy: { id: Prisma.SortOrder.desc }, + }); return json({ phoneNumbers }); }; +export const action = settingsPhoneAction; + function PhoneSettings() { return (
diff --git a/app/routes/twilio.authorize.ts b/app/routes/twilio.authorize.ts index 58e186d..272e35d 100644 --- a/app/routes/twilio.authorize.ts +++ b/app/routes/twilio.authorize.ts @@ -1,26 +1,65 @@ import { type LoaderFunction, redirect } from "@remix-run/node"; -import { refreshSessionData, requireLoggedIn } from "~/utils/auth.server"; -import db from "~/utils/db.server"; -import { commitSession } from "~/utils/session.server"; import twilio from "twilio"; + +import { refreshSessionData, requireLoggedIn } from "~/utils/auth.server"; +import { commitSession } from "~/utils/session.server"; +import db from "~/utils/db.server"; import serverConfig from "~/config/config.server"; +import getTwilioClient from "~/utils/twilio.server"; +import fetchPhoneCallsQueue from "~/queues/fetch-phone-calls.server"; +import fetchMessagesQueue from "~/queues/fetch-messages.server"; export const loader: LoaderFunction = async ({ request }) => { const user = await requireLoggedIn(request); + const organization = user.organizations[0]; const url = new URL(request.url); const twilioSubAccountSid = url.searchParams.get("AccountSid"); if (!twilioSubAccountSid) { throw new Error("unreachable"); } - const twilioClient = twilio(twilioSubAccountSid, serverConfig.twilio.authToken); + let twilioClient = twilio(twilioSubAccountSid, serverConfig.twilio.authToken); const twilioSubAccount = await twilioClient.api.accounts(twilioSubAccountSid).fetch(); const twilioAccountSid = twilioSubAccount.ownerAccountSid; await db.organization.update({ - where: { id: user.organizations[0].id }, + where: { id: organization.id }, data: { twilioSubAccountSid, twilioAccountSid }, }); + twilioClient = getTwilioClient({ twilioAccountSid, twilioSubAccountSid }); + const phoneNumbers = await twilioClient.incomingPhoneNumbers.list(); + await Promise.all( + phoneNumbers.map(async (phoneNumber) => { + const phoneNumberId = phoneNumber.sid; + try { + await db.phoneNumber.create({ + data: { + id: phoneNumberId, + organizationId: organization.id, + 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; + } + } + }), + ); + const { session } = await refreshSessionData(request); return redirect("/settings/phone", { headers: { diff --git a/app/utils/twilio.server.ts b/app/utils/twilio.server.ts index 4a20b78..7167c3b 100644 --- a/app/utils/twilio.server.ts +++ b/app/utils/twilio.server.ts @@ -1,16 +1,92 @@ import twilio from "twilio"; +import type { MessageInstance } from "twilio/lib/rest/api/v2010/account/message"; +import type { CallInstance } from "twilio/lib/rest/api/v2010/account/call"; +import { type Organization, CallStatus, Direction, MessageStatus } from "@prisma/client"; -import type { Organization } from "@prisma/client"; import serverConfig from "~/config/config.server"; type MinimalOrganization = Pick; -export default function getTwilioClient(organization: MinimalOrganization): twilio.Twilio { - if (!organization || !organization.twilioSubAccountSid || !organization.twilioAccountSid) { +export default function getTwilioClient({ twilioAccountSid, twilioSubAccountSid }: MinimalOrganization): twilio.Twilio { + if (!twilioSubAccountSid || !twilioAccountSid) { throw new Error("unreachable"); } - return twilio(organization.twilioSubAccountSid, serverConfig.twilio.authToken, { - accountSid: organization.twilioAccountSid, + return twilio(twilioSubAccountSid, serverConfig.twilio.authToken, { + accountSid: twilioAccountSid, }); } + +export function translateMessageStatus(status: MessageInstance["status"]): MessageStatus { + switch (status) { + case "accepted": + return MessageStatus.Accepted; + case "canceled": + return MessageStatus.Canceled; + case "delivered": + return MessageStatus.Delivered; + case "failed": + return MessageStatus.Failed; + case "partially_delivered": + return MessageStatus.PartiallyDelivered; + case "queued": + return MessageStatus.Queued; + case "read": + return MessageStatus.Read; + case "received": + return MessageStatus.Received; + case "receiving": + return MessageStatus.Receiving; + case "scheduled": + return MessageStatus.Scheduled; + case "sending": + return MessageStatus.Sending; + case "sent": + return MessageStatus.Sent; + case "undelivered": + return MessageStatus.Undelivered; + } +} + +export function translateMessageDirection(direction: MessageInstance["direction"]): Direction { + switch (direction) { + case "inbound": + return Direction.Inbound; + case "outbound-api": + case "outbound-call": + case "outbound-reply": + default: + return Direction.Outbound; + } +} + +export function translateCallStatus(status: CallInstance["status"]): CallStatus { + switch (status) { + case "busy": + return CallStatus.Busy; + case "canceled": + return CallStatus.Canceled; + case "completed": + return CallStatus.Completed; + case "failed": + return CallStatus.Failed; + case "in-progress": + return CallStatus.InProgress; + case "no-answer": + return CallStatus.NoAnswer; + case "queued": + return CallStatus.Queued; + case "ringing": + return CallStatus.Ringing; + } +} + +export function translateCallDirection(direction: CallInstance["direction"]): Direction { + switch (direction) { + case "inbound": + return Direction.Inbound; + case "outbound": + default: + return Direction.Outbound; + } +} diff --git a/package-lock.json b/package-lock.json index a2fc1d3..3097d54 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,6 +41,7 @@ "remix-utils": "3.2.0", "secure-password": "4.0.0", "stripe": "9.1.0", + "superjson-remix": "0.1.2", "tiny-invariant": "1.2.0", "tslog": "3.3.3", "twilio": "3.77.0", @@ -6898,6 +6899,20 @@ "node": ">=6.6.0" } }, + "node_modules/copy-anything": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-3.0.2.tgz", + "integrity": "sha512-CzATjGXzUQ0EvuvgOCI6A4BGOo2bcVx8B+eC2nF862iv9fopnPQwlrbACakNCHRIJbCSBj+J/9JeDf60k64MkA==", + "dependencies": { + "is-what": "^4.1.6" + }, + "engines": { + "node": ">=12.13" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, "node_modules/copy-descriptor": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/copy-descriptor/-/copy-descriptor-0.1.1.tgz", @@ -11947,6 +11962,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-what": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-4.1.7.tgz", + "integrity": "sha512-DBVOQNiPKnGMxRMLIYSwERAS5MVY1B7xYiGnpgctsOFvVDz9f9PFXXxMcTOHuoqYp4NK9qFYQaIC1NRRxLMpBQ==", + "engines": { + "node": ">=12.13" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, "node_modules/is-whitespace": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/is-whitespace/-/is-whitespace-0.3.0.tgz", @@ -19751,6 +19777,32 @@ "postcss": "^8.2.15" } }, + "node_modules/superjson": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-1.9.1.tgz", + "integrity": "sha512-oT3HA2nPKlU1+5taFgz/HDy+GEaY+CWEbLzaRJVD4gZ7zMVVC4GDNFdgvAZt6/VuIk6D2R7RtPAiCHwmdzlMmg==", + "dependencies": { + "copy-anything": "^3.0.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/superjson-remix": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/superjson-remix/-/superjson-remix-0.1.2.tgz", + "integrity": "sha512-NkJd+V0zOMCUbwnaKsxNs1FXMWTAHppqaCTd9IKXDOoPtRkjxU8/7duQeJ9yC8L3J9eJ47CShkIS+NEhzlY6IQ==", + "hasInstallScript": true, + "dependencies": { + "superjson": "^1.8.1" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "remix": ">=1.2.3" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -26805,6 +26857,14 @@ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.0.tgz", "integrity": "sha512-R0BOPfLGTitaKhgKROKZQN6iyq2iDQcH1DOF8nJoaWapguX5bC2w+Q/I9NmmM5lfcvEarnLZr+cCvmEYYSXvYA==" }, + "copy-anything": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-3.0.2.tgz", + "integrity": "sha512-CzATjGXzUQ0EvuvgOCI6A4BGOo2bcVx8B+eC2nF862iv9fopnPQwlrbACakNCHRIJbCSBj+J/9JeDf60k64MkA==", + "requires": { + "is-what": "^4.1.6" + } + }, "copy-descriptor": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/copy-descriptor/-/copy-descriptor-0.1.1.tgz", @@ -30493,6 +30553,11 @@ "call-bind": "^1.0.2" } }, + "is-what": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-4.1.7.tgz", + "integrity": "sha512-DBVOQNiPKnGMxRMLIYSwERAS5MVY1B7xYiGnpgctsOFvVDz9f9PFXXxMcTOHuoqYp4NK9qFYQaIC1NRRxLMpBQ==" + }, "is-whitespace": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/is-whitespace/-/is-whitespace-0.3.0.tgz", @@ -36394,6 +36459,22 @@ "postcss-selector-parser": "^6.0.4" } }, + "superjson": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-1.9.1.tgz", + "integrity": "sha512-oT3HA2nPKlU1+5taFgz/HDy+GEaY+CWEbLzaRJVD4gZ7zMVVC4GDNFdgvAZt6/VuIk6D2R7RtPAiCHwmdzlMmg==", + "requires": { + "copy-anything": "^3.0.2" + } + }, + "superjson-remix": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/superjson-remix/-/superjson-remix-0.1.2.tgz", + "integrity": "sha512-NkJd+V0zOMCUbwnaKsxNs1FXMWTAHppqaCTd9IKXDOoPtRkjxU8/7duQeJ9yC8L3J9eJ47CShkIS+NEhzlY6IQ==", + "requires": { + "superjson": "^1.8.1" + } + }, "supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", diff --git a/package.json b/package.json index 32d14d3..be5c18c 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,7 @@ "remix-utils": "3.2.0", "secure-password": "4.0.0", "stripe": "9.1.0", + "superjson-remix": "0.1.2", "tiny-invariant": "1.2.0", "tslog": "3.3.3", "twilio": "3.77.0",