From 6684dcc0e584e46ea560d1712d322c38c770fbc8 Mon Sep 17 00:00:00 2001 From: m5r Date: Sat, 21 May 2022 21:33:23 +0200 Subject: [PATCH] store twilio stuff in TwilioAccount table and remodel session data --- .../messages/actions/messages.$recipient.tsx | 15 +- .../messages/components/conversation.tsx | 12 +- .../components/new-message-bottom-sheet.tsx | 8 +- app/features/messages/loaders/messages.ts | 141 ++++----- app/features/settings/actions/account.ts | 30 +- app/features/settings/actions/phone.ts | 9 +- .../account/profile-informations.tsx | 2 +- .../components/phone/phone-number-form.tsx | 6 +- .../components/phone/twilio-connect.tsx | 4 +- app/queues/fetch-messages.server.ts | 15 +- app/queues/fetch-phone-calls.server.ts | 15 +- app/routes/__app.tsx | 29 +- app/routes/__app/calls.tsx | 9 +- app/routes/__app/keypad.tsx | 2 +- app/routes/__app/messages.$recipient.tsx | 5 +- app/routes/__app/settings/phone.tsx | 5 +- app/routes/twilio.authorize.ts | 22 +- app/utils/auth.server.ts | 113 +++++--- app/utils/authenticator.server.ts | 4 +- app/utils/session.server.ts | 7 +- app/utils/twilio.server.ts | 32 ++- .../20220517184134_init/migration.sql | 23 +- prisma/schema.prisma | 268 +++++++++--------- 23 files changed, 411 insertions(+), 365 deletions(-) diff --git a/app/features/messages/actions/messages.$recipient.tsx b/app/features/messages/actions/messages.$recipient.tsx index 3ad445f..19ce5fd 100644 --- a/app/features/messages/actions/messages.$recipient.tsx +++ b/app/features/messages/actions/messages.$recipient.tsx @@ -8,19 +8,14 @@ import getTwilioClient, { translateMessageDirection, translateMessageStatus } fr export type NewMessageActionData = {}; const action: ActionFunction = async ({ params, request }) => { - const user = await requireLoggedIn(request); - const organization = user.organizations[0]; - const phoneNumber = await db.phoneNumber.findUnique({ - where: { organizationId_isCurrent: { organizationId: user.organizations[0].id, isCurrent: true } }, - }); + const { phoneNumber, twilioAccount } = await requireLoggedIn(request); + if (!twilioAccount) { + throw new Error("unreachable"); + } const recipient = decodeURIComponent(params.recipient ?? ""); const formData = Object.fromEntries(await request.formData()); - - const { twilioAccountSid, twilioSubAccountSid } = organization; - // const twilioClient = getTwilioClient({ twilioSubAccountSid, twilioAccountSid }); - const twilioClient = getTwilioClient({ twilioSubAccountSid: twilioAccountSid, twilioAccountSid }); + const twilioClient = getTwilioClient(twilioAccount); try { - console.log({ twilioAccountSid, twilioSubAccountSid }); console.log({ body: formData.content.toString(), to: recipient, diff --git a/app/features/messages/components/conversation.tsx b/app/features/messages/components/conversation.tsx index 9c5ac38..480182d 100644 --- a/app/features/messages/components/conversation.tsx +++ b/app/features/messages/components/conversation.tsx @@ -10,7 +10,7 @@ import { type ConversationLoaderData } from "~/routes/__app/messages.$recipient" import useSession from "~/features/core/hooks/use-session"; export default function Conversation() { - const { currentPhoneNumber } = useSession(); + const { phoneNumber } = useSession(); const params = useParams<{ recipient: string }>(); const recipient = decodeURIComponent(params.recipient ?? ""); const { conversation } = useLoaderData(); @@ -21,15 +21,15 @@ export default function Conversation() { if (transition.submission) { messages.push({ id: "temp", - phoneNumberId: currentPhoneNumber.id, - from: currentPhoneNumber.number, + phoneNumberId: phoneNumber!.id, + from: phoneNumber!.number, to: recipient, sentAt: new Date(), direction: Direction.Outbound, status: "Queued", - content: transition.submission.formData.get("content")!.toString() - }) + content: transition.submission.formData.get("content")!.toString(), + }); } useEffect(() => { @@ -91,7 +91,7 @@ export default function Conversation() { })} - + ); } diff --git a/app/features/messages/components/new-message-bottom-sheet.tsx b/app/features/messages/components/new-message-bottom-sheet.tsx index 53b407e..77c9052 100644 --- a/app/features/messages/components/new-message-bottom-sheet.tsx +++ b/app/features/messages/components/new-message-bottom-sheet.tsx @@ -20,8 +20,8 @@ export default function NewMessageBottomSheet() { onClose={() => setIsOpen(false)} snapPoints={[0.5]} > - - + +
New Message @@ -30,7 +30,7 @@ export default function NewMessageBottomSheet() {
- +
To: @@ -48,7 +48,7 @@ export default function NewMessageBottomSheet() { - setIsOpen(false)} /> + setIsOpen(false)} /> ); } diff --git a/app/features/messages/loaders/messages.ts b/app/features/messages/loaders/messages.ts index 5b3d7d5..77d4bfe 100644 --- a/app/features/messages/loaders/messages.ts +++ b/app/features/messages/loaders/messages.ts @@ -1,10 +1,10 @@ import type { LoaderFunction } from "@remix-run/node"; import { json } from "superjson-remix"; import { parsePhoneNumber } from "awesome-phonenumber"; -import { type Message, Prisma, Direction, SubscriptionStatus } from "@prisma/client"; +import { type Message, Prisma, Direction } from "@prisma/client"; import db from "~/utils/db.server"; -import { requireLoggedIn } from "~/utils/auth.server"; +import { requireLoggedIn, type SessionData } from "~/utils/auth.server"; export type MessagesLoaderData = { user: { hasPhoneNumber: boolean }; @@ -18,95 +18,58 @@ type Conversation = { }; const loader: LoaderFunction = async ({ request }) => { - const { id, organizations } = await requireLoggedIn(request); - const user = await db.user.findFirst({ - where: { id }, - select: { - id: true, - fullName: true, - email: true, - role: true, - memberships: { - include: { - organization: { - include: { - subscriptions: { - where: { - OR: [ - { status: { not: SubscriptionStatus.deleted } }, - { - status: SubscriptionStatus.deleted, - cancellationEffectiveDate: { gt: new Date() }, - }, - ], - }, - orderBy: { lastEventTime: Prisma.SortOrder.desc }, - }, - }, - }, - }, - }, - }, - }); - 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, - number: true, - }, - }); - const conversations = await getConversations(); - + const sessionData = await requireLoggedIn(request); return json({ - user: { hasPhoneNumber: Boolean(phoneNumber) }, - conversations, + user: { hasPhoneNumber: Boolean(sessionData.phoneNumber) }, + conversations: await getConversations(sessionData.phoneNumber), }); - - async function getConversations() { - const organizationId = organizations[0].id; - const phoneNumber = await db.phoneNumber.findUnique({ - where: { organizationId_isCurrent: { organizationId, isCurrent: true } }, - }); - if (!phoneNumber || phoneNumber.isFetchingMessages) { - return; - } - - const messages = await db.message.findMany({ - where: { phoneNumberId: phoneNumber.id }, - orderBy: { sentAt: Prisma.SortOrder.desc }, - }); - - let conversations: Record = {}; - for (const message of messages) { - let recipient: string; - if (message.direction === Direction.Outbound) { - recipient = message.to; - } else { - recipient = message.from; - } - const formattedPhoneNumber = parsePhoneNumber(recipient).getNumber("international"); - - if (!conversations[recipient]) { - conversations[recipient] = { - recipient, - formattedPhoneNumber, - lastMessage: message, - }; - } - - if (message.sentAt > conversations[recipient].lastMessage.sentAt) { - conversations[recipient].lastMessage = message; - } - /*conversations[recipient]!.messages.push({ - ...message, - content: decrypt(message.content, organization.encryptionKey), - });*/ - } - - return conversations; - } }; export default loader; + +async function getConversations(sessionPhoneNumber: SessionData["phoneNumber"]) { + if (!sessionPhoneNumber) { + return; + } + + const phoneNumber = await db.phoneNumber.findUnique({ + where: { id: sessionPhoneNumber.id }, + }); + if (!phoneNumber || phoneNumber.isFetchingMessages) { + return; + } + + const messages = await db.message.findMany({ + where: { phoneNumberId: phoneNumber.id }, + orderBy: { sentAt: Prisma.SortOrder.desc }, + }); + + let conversations: Record = {}; + for (const message of messages) { + let recipient: string; + if (message.direction === Direction.Outbound) { + recipient = message.to; + } else { + recipient = message.from; + } + const formattedPhoneNumber = parsePhoneNumber(recipient).getNumber("international"); + + if (!conversations[recipient]) { + conversations[recipient] = { + recipient, + formattedPhoneNumber, + lastMessage: message, + }; + } + + if (message.sentAt > conversations[recipient].lastMessage.sentAt) { + conversations[recipient].lastMessage = message; + } + /*conversations[recipient]!.messages.push({ + ...message, + content: decrypt(message.content, organization.encryptionKey), + });*/ + } + + return conversations; +} diff --git a/app/features/settings/actions/account.ts b/app/features/settings/actions/account.ts index cdb2f42..3cbadee 100644 --- a/app/features/settings/actions/account.ts +++ b/app/features/settings/actions/account.ts @@ -19,23 +19,25 @@ const action: ActionFunction = async ({ request }) => { } switch (formData._action as Action) { - case "deleteUser": - return deleteUser(request); - case "changePassword": - return changePassword(request, formData); - case "updateUser": - return updateUser(request, formData); - default: - const errorMessage = `POST /settings with an invalid _action=${formData._action}`; - logger.error(errorMessage); - return badRequest({ errorMessage }); + case "deleteUser": + return deleteUser(request); + case "changePassword": + return changePassword(request, formData); + case "updateUser": + return updateUser(request, formData); + default: + const errorMessage = `POST /settings with an invalid _action=${formData._action}`; + logger.error(errorMessage); + return badRequest({ errorMessage }); } }; export default action; async function deleteUser(request: Request) { - const { id } = await requireLoggedIn(request); + const { + user: { id }, + } = await requireLoggedIn(request); await db.user.update({ where: { id }, @@ -64,7 +66,9 @@ async function changePassword(request: Request, formData: unknown) { }); } - const { id } = await requireLoggedIn(request); + const { + user: { id }, + } = await requireLoggedIn(request); const user = await db.user.findUnique({ where: { id } }); const { currentPassword, newPassword } = validation.data; const verificationResult = await verifyPassword(user!.hashedPassword!, currentPassword); @@ -99,7 +103,7 @@ async function updateUser(request: Request, formData: unknown) { }); } - const user = await requireLoggedIn(request); + const { user } = await requireLoggedIn(request); const { email, fullName } = validation.data; await db.user.update({ where: { id: user.id }, diff --git a/app/features/settings/actions/phone.ts b/app/features/settings/actions/phone.ts index 0413c0b..d147dd5 100644 --- a/app/features/settings/actions/phone.ts +++ b/app/features/settings/actions/phone.ts @@ -5,14 +5,14 @@ import { z } from "zod"; import db from "~/utils/db.server"; import { type FormError, validate } from "~/utils/validation.server"; import { requireLoggedIn } from "~/utils/auth.server"; +import setTwilioWebhooksQueue from "~/queues/set-twilio-webhooks.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 { organization } = await requireLoggedIn(request); const formData = Object.fromEntries(await request.formData()); const validation = validate(bodySchema, formData); if (validation.errors) { @@ -35,6 +35,11 @@ const action: ActionFunction = async ({ request }) => { where: { id: validation.data.phoneNumberSid }, data: { isCurrent: true }, }); + await setTwilioWebhooksQueue.add(`set twilio webhooks for phoneNumberId=${validation.data.phoneNumberSid}`, { + phoneNumberId: validation.data.phoneNumberSid, + organizationId: organization.id, + }); + console.log("queued"); return json({ submitted: true }); }; diff --git a/app/features/settings/components/account/profile-informations.tsx b/app/features/settings/components/account/profile-informations.tsx index a893308..6dca7a2 100644 --- a/app/features/settings/components/account/profile-informations.tsx +++ b/app/features/settings/components/account/profile-informations.tsx @@ -8,7 +8,7 @@ import Button from "../button"; import SettingsSection from "../settings-section"; const ProfileInformations: FunctionComponent = () => { - const user = useSession(); + const { user } = useSession(); const transition = useTransition(); const actionData = useActionData()?.updateUser; diff --git a/app/features/settings/components/phone/phone-number-form.tsx b/app/features/settings/components/phone/phone-number-form.tsx index 56d3d8d..cc72a7d 100644 --- a/app/features/settings/components/phone/phone-number-form.tsx +++ b/app/features/settings/components/phone/phone-number-form.tsx @@ -10,7 +10,7 @@ import type { SetPhoneNumberActionData } from "~/features/settings/actions/phone export default function PhoneNumberForm() { const transition = useTransition(); const actionData = useActionData(); - const { currentOrganization } = useSession(); + const { twilioAccount } = useSession(); const availablePhoneNumbers = useLoaderData().phoneNumbers; const isSubmitting = transition.state === "submitting"; @@ -18,8 +18,8 @@ export default function PhoneNumberForm() { 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 currentPhoneNumber = availablePhoneNumbers.find((phoneNumber) => phoneNumber.isCurrent === true); + const hasFilledTwilioCredentials = twilioAccount !== null; if (!hasFilledTwilioCredentials) { return null; diff --git a/app/features/settings/components/phone/twilio-connect.tsx b/app/features/settings/components/phone/twilio-connect.tsx index fc39db5..8d89539 100644 --- a/app/features/settings/components/phone/twilio-connect.tsx +++ b/app/features/settings/components/phone/twilio-connect.tsx @@ -6,7 +6,7 @@ import SettingsSection from "../settings-section"; import useSession from "~/features/core/hooks/use-session"; export default function TwilioConnect() { - const { currentOrganization } = useSession(); + const { twilioAccount } = useSession(); const [isHelpModalOpen, setIsHelpModalOpen] = useState(false); return ( @@ -20,7 +20,7 @@ export default function TwilioConnect() { Shellphone needs to connect to your Twilio account to securely use your phone numbers. - {currentOrganization.twilioAccountSid === null ? ( + {twilioAccount === null ? ( ("fetch messages", async ({ data }) => { const { phoneNumberId } = data; const phoneNumber = await db.phoneNumber.findUnique({ where: { id: phoneNumberId }, - include: { organization: true }, + include: { + organization: { + select: { twilioAccount: true }, + }, + }, }); if (!phoneNumber) { logger.warn(`No phone number found with id=${phoneNumberId}`); return; } - const organization = phoneNumber.organization; - const twilioClient = getTwilioClient(organization); + 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 [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 f5ee5c4..bf5ec40 100644 --- a/app/queues/fetch-phone-calls.server.ts +++ b/app/queues/fetch-phone-calls.server.ts @@ -12,15 +12,24 @@ export default Queue("fetch phone calls", async ({ data }) => { const { phoneNumberId } = data; const phoneNumber = await db.phoneNumber.findUnique({ where: { id: phoneNumberId }, - include: { organization: true }, + include: { + organization: { + select: { twilioAccount: true }, + }, + }, }); if (!phoneNumber) { logger.warn(`No phone number found with id=${phoneNumberId}`); return; } - const organization = phoneNumber.organization; - const twilioClient = getTwilioClient(organization); + 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 [callsSent, callsReceived] = await Promise.all([ twilioClient.calls.list({ from: phoneNumber.number }), twilioClient.calls.list({ to: phoneNumber.number }), diff --git a/app/routes/__app.tsx b/app/routes/__app.tsx index 358b741..9200985 100644 --- a/app/routes/__app.tsx +++ b/app/routes/__app.tsx @@ -1,41 +1,20 @@ import { type LoaderFunction, json } from "@remix-run/node"; import { Outlet, useCatch, useMatches } from "@remix-run/react"; -import { type SessionData, type SessionOrganization, requireLoggedIn } from "~/utils/auth.server"; +import { type SessionData, requireLoggedIn } from "~/utils/auth.server"; import Footer from "~/features/core/components/footer"; -import db from "~/utils/db.server"; export type AppLoaderData = SessionData; export const loader: LoaderFunction = async ({ request }) => { - const user = await requireLoggedIn(request); - const organization = await db.organization.findUnique({ - where: { id: user.organizations[0].id }, - include: { - memberships: { - where: { userId: user.id }, - select: { role: true }, - }, - phoneNumbers: { - where: { isCurrent: true }, - select: { id: true, number: true }, - }, - }, - }); - const currentOrganization: SessionOrganization = { - id: organization!.id, - twilioAccountSid: organization!.twilioAccountSid, - twilioSubAccountSid: organization!.twilioSubAccountSid, - role: organization!.memberships[0].role, - }; - const currentPhoneNumber = organization!.phoneNumbers[0]; + const sessionData = await requireLoggedIn(request); - return json({ ...user, currentOrganization, currentPhoneNumber }); + return json(sessionData); }; export default function __App() { const matches = useMatches(); - const hideFooter = matches.some(match => match.handle?.hideFooter === true); + const hideFooter = matches.some((match) => match.handle?.hideFooter === true); return (
diff --git a/app/routes/__app/calls.tsx b/app/routes/__app/calls.tsx index 497ee72..477349a 100644 --- a/app/routes/__app/calls.tsx +++ b/app/routes/__app/calls.tsx @@ -26,10 +26,13 @@ export type PhoneCallsLoaderData = { }; export const loader: LoaderFunction = async ({ request }) => { - const { organizations } = await requireLoggedIn(request); - const organizationId = organizations[0].id; + const sessionData = await requireLoggedIn(request); + if (!sessionData.phoneNumber) { + throw new Error("unreachable"); + } + const phoneNumber = await db.phoneNumber.findUnique({ - where: { organizationId_isCurrent: { organizationId, isCurrent: true } }, + where: { id: sessionData.phoneNumber.id }, }); if (!phoneNumber || phoneNumber.isFetchingCalls) { return json({ diff --git a/app/routes/__app/keypad.tsx b/app/routes/__app/keypad.tsx index 0ecb2ad..4da1f44 100644 --- a/app/routes/__app/keypad.tsx +++ b/app/routes/__app/keypad.tsx @@ -13,7 +13,7 @@ import useKeyPress from "~/features/keypad/hooks/use-key-press"; import KeypadErrorModal from "~/features/keypad/components/keypad-error-modal"; import InactiveSubscription from "~/features/core/components/inactive-subscription"; -export default function SettingsLayout() { +export default function KeypadPage() { const { hasFilledTwilioCredentials, hasPhoneNumber, hasOngoingSubscription } = { hasFilledTwilioCredentials: false, hasPhoneNumber: false, diff --git a/app/routes/__app/messages.$recipient.tsx b/app/routes/__app/messages.$recipient.tsx index bb7a35b..a7371e6 100644 --- a/app/routes/__app/messages.$recipient.tsx +++ b/app/routes/__app/messages.$recipient.tsx @@ -1,4 +1,3 @@ -import { Suspense } from "react"; import type { LoaderFunction, MetaFunction } from "@remix-run/node"; import { Link, useNavigate, useParams } from "@remix-run/react"; import { json, useLoaderData } from "superjson-remix"; @@ -35,14 +34,14 @@ export type ConversationLoaderData = { }; export const loader: LoaderFunction = async ({ request, params }) => { - const { organizations } = await requireLoggedIn(request); + const { organization } = await requireLoggedIn(request); const recipient = decodeURIComponent(params.recipient ?? ""); const conversation = await getConversation(recipient); return json({ conversation }); async function getConversation(recipient: string): Promise { - const organizationId = organizations[0].id; + const organizationId = organization.id; const phoneNumber = await db.phoneNumber.findUnique({ where: { organizationId_isCurrent: { organizationId, isCurrent: true } }, }); diff --git a/app/routes/__app/settings/phone.tsx b/app/routes/__app/settings/phone.tsx index 3888b86..e231120 100644 --- a/app/routes/__app/settings/phone.tsx +++ b/app/routes/__app/settings/phone.tsx @@ -13,9 +13,8 @@ export type PhoneSettingsLoaderData = { }; export const loader: LoaderFunction = async ({ request }) => { - const { organizations } = await requireLoggedIn(request); - const organization = organizations[0]; - if (!organization.twilioAccountSid) { + const { organization, twilioAccount } = await requireLoggedIn(request); + if (!twilioAccount) { logger.warn("Twilio account is not connected"); return json({ phoneNumbers: [] }); } diff --git a/app/routes/twilio.authorize.ts b/app/routes/twilio.authorize.ts index 272e35d..554ab0e 100644 --- a/app/routes/twilio.authorize.ts +++ b/app/routes/twilio.authorize.ts @@ -8,10 +8,10 @@ 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"; +import { encrypt } from "~/utils/encryption"; export const loader: LoaderFunction = async ({ request }) => { - const user = await requireLoggedIn(request); - const organization = user.organizations[0]; + const { organization } = await requireLoggedIn(request); const url = new URL(request.url); const twilioSubAccountSid = url.searchParams.get("AccountSid"); if (!twilioSubAccountSid) { @@ -20,13 +20,21 @@ export const loader: LoaderFunction = async ({ request }) => { 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: organization.id }, - data: { twilioSubAccountSid, twilioAccountSid }, + const twilioMainAccountSid = twilioSubAccount.ownerAccountSid; + const twilioMainAccount = await twilioClient.api.accounts(twilioMainAccountSid).fetch(); + console.log("twilioSubAccount", twilioSubAccount); + console.log("twilioAccount", twilioMainAccount); + const twilioAccount = await db.twilioAccount.update({ + where: { organizationId: organization.id }, + data: { + subAccountSid: twilioSubAccount.sid, + subAccountAuthToken: encrypt(twilioSubAccount.authToken), + accountSid: twilioMainAccount.sid, + accountAuthToken: encrypt(twilioMainAccount.authToken), + }, }); - twilioClient = getTwilioClient({ twilioAccountSid, twilioSubAccountSid }); + twilioClient = getTwilioClient(twilioAccount); const phoneNumbers = await twilioClient.incomingPhoneNumbers.list(); await Promise.all( phoneNumbers.map(async (phoneNumber) => { diff --git a/app/utils/auth.server.ts b/app/utils/auth.server.ts index 4d76854..0260729 100644 --- a/app/utils/auth.server.ts +++ b/app/utils/auth.server.ts @@ -1,26 +1,31 @@ import { redirect, type Session } from "@remix-run/node"; import type { FormStrategyVerifyParams } from "remix-auth-form"; import SecurePassword from "secure-password"; -import type { MembershipRole, Organization, PhoneNumber, User } from "@prisma/client"; +import type { MembershipRole, Organization, PhoneNumber, TwilioAccount, User } from "@prisma/client"; import db from "./db.server"; import logger from "./logger.server"; import authenticator from "./authenticator.server"; -import { AuthenticationError } from "./errors"; +import { AuthenticationError, NotFoundError } from "./errors"; import { commitSession, destroySession, getSession } from "./session.server"; -export type SessionOrganization = Pick & { - role: MembershipRole; +type SessionTwilioAccount = Pick< + TwilioAccount, + "accountSid" | "accountAuthToken" | "subAccountSid" | "subAccountAuthToken" | "twimlAppSid" +>; +type SessionOrganization = Pick & { role: MembershipRole }; +type SessionPhoneNumber = Pick; +export type SessionUser = Pick; +export type SessionData = { + user: SessionUser; + organization: SessionOrganization; + phoneNumber: SessionPhoneNumber | null; + twilioAccount: SessionTwilioAccount | null; }; -export type SessionPhoneNumber = Pick; -export type SessionUser = Omit & { - organizations: SessionOrganization[]; -}; -export type SessionData = SessionUser & { currentOrganization: SessionOrganization; currentPhoneNumber: SessionPhoneNumber }; const SP = new SecurePassword(); -export async function login({ form }: FormStrategyVerifyParams): Promise { +export async function login({ form }: FormStrategyVerifyParams): Promise { const email = form.get("email"); const password = form.get("password"); const isEmailValid = typeof email === "string" && email.length > 0; @@ -36,21 +41,8 @@ export async function login({ form }: FormStrategyVerifyParams): Promise ({ - ...membership.organization, - role: membership.role, - })); + try { + return await buildSessionData(user.id); + } catch (error: any) { + if (error instanceof AuthenticationError) { + throw error; + } - return { - ...rest, - organizations, - }; + throw new AuthenticationError("Incorrect password"); + } } export async function verifyPassword(hashedPassword: string, password: string) { @@ -114,9 +105,10 @@ export async function authenticate({ method: "post", headers: request.headers, }); - const user = await authenticator.authenticate("email-password", signInRequest, { failureRedirect }); + const sessionData = await authenticator.authenticate("email-password", signInRequest, { failureRedirect }); + console.log("sessionKey", authenticator.sessionKey); const session = await getSession(request); - session.set(authenticator.sessionKey, user); + session.set(authenticator.sessionKey, sessionData); const redirectTo = successRedirect ?? "/messages"; return redirect(redirectTo, { headers: { "Set-Cookie": await commitSession(session) }, @@ -161,23 +153,50 @@ function buildRedirectTo(url: URL) { } export async function refreshSessionData(request: Request) { - const { id } = await requireLoggedIn(request); + const { + user: { id }, + } = await requireLoggedIn(request); + const user = await db.user.findUnique({ where: { id } }); + if (!user || !user.hashedPassword) { + logger.warn(`User with id=${id} not found`); + throw new AuthenticationError("Could not refresh session, user does not exist"); + } + + const sessionData = await buildSessionData(id); + const session = await getSession(request); + session.set(authenticator.sessionKey, sessionData); + + return { session, sessionData: sessionData }; +} + +async function buildSessionData(id: string): Promise { const user = await db.user.findUnique({ where: { id }, include: { memberships: { select: { organization: { - select: { id: true, twilioSubAccountSid: true, twilioAccountSid: true }, + select: { + id: true, + twilioAccount: { + select: { + accountSid: true, + accountAuthToken: true, + subAccountSid: true, + subAccountAuthToken: true, + twimlAppSid: true, + }, + }, + }, }, role: true, }, }, }, }); - if (!user || !user.hashedPassword) { + if (!user) { logger.warn(`User with id=${id} not found`); - throw new AuthenticationError("Could not refresh session, user does not exist"); + throw new NotFoundError(`User with id=${id} not found`); } const { hashedPassword, memberships, ...rest } = user; @@ -185,12 +204,14 @@ export async function refreshSessionData(request: Request) { ...membership.organization, role: membership.role, })); - const sessionUser: SessionUser = { - ...rest, - organizations, + const { twilioAccount, ...organization } = organizations[0]; + const phoneNumber = await db.phoneNumber.findUnique({ + where: { organizationId_isCurrent: { organizationId: organization.id, isCurrent: true } }, + }); + return { + user: rest, + organization, + phoneNumber, + twilioAccount, }; - const session = await getSession(request); - session.set(authenticator.sessionKey, sessionUser); - - return { session, user: sessionUser }; } diff --git a/app/utils/authenticator.server.ts b/app/utils/authenticator.server.ts index e9a6f09..f8fb892 100644 --- a/app/utils/authenticator.server.ts +++ b/app/utils/authenticator.server.ts @@ -2,9 +2,9 @@ import { Authenticator } from "remix-auth"; import { FormStrategy } from "remix-auth-form"; import { sessionStorage } from "./session.server"; -import { type SessionUser, login } from "./auth.server"; +import { type SessionData, login } from "./auth.server"; -const authenticator = new Authenticator(sessionStorage); +const authenticator = new Authenticator(sessionStorage); authenticator.use(new FormStrategy(login), "email-password"); diff --git a/app/utils/session.server.ts b/app/utils/session.server.ts index bf9cb95..751dbc4 100644 --- a/app/utils/session.server.ts +++ b/app/utils/session.server.ts @@ -3,6 +3,8 @@ import { type Session, type SessionIdStorageStrategy, createSessionStorage } fro import serverConfig from "~/config/config.server"; import db from "./db.server"; import logger from "./logger.server"; +import authenticator from "~/utils/authenticator.server"; +import type { SessionData } from "~/utils/auth.server"; const SECOND = 1; const MINUTE = 60 * SECOND; @@ -32,8 +34,9 @@ function createDatabaseSessionStorage({ cookie }: Pick; - -export default function getTwilioClient({ twilioAccountSid, twilioSubAccountSid }: MinimalOrganization): twilio.Twilio { - if (!twilioSubAccountSid || !twilioAccountSid) { +export default function getTwilioClient({ + accountSid, + subAccountSid, + subAccountAuthToken, +}: Pick & + Partial>): twilio.Twilio { + if (!subAccountSid || !accountSid) { throw new Error("unreachable"); } - return twilio(twilioSubAccountSid, serverConfig.twilio.authToken, { - accountSid: twilioAccountSid, + return twilio(subAccountSid, serverConfig.twilio.authToken, { + accountSid, }); } +export const smsUrl = `https://${serverConfig.app.baseUrl}/webhook/message`; + +export const voiceUrl = `https://${serverConfig.app.baseUrl}/webhook/call`; + +export function getTwiMLName() { + switch (serverConfig.app.baseUrl) { + case "local.shellphone.app": + return "Shellphone LOCAL"; + case "dev.shellphone.app": + return "Shellphone DEV"; + case "www.shellphone.app": + return "Shellphone"; + } +} + export function translateMessageStatus(status: MessageInstance["status"]): MessageStatus { switch (status) { case "accepted": diff --git a/prisma/migrations/20220517184134_init/migration.sql b/prisma/migrations/20220517184134_init/migration.sql index 40219f0..6749838 100644 --- a/prisma/migrations/20220517184134_init/migration.sql +++ b/prisma/migrations/20220517184134_init/migration.sql @@ -19,13 +19,25 @@ CREATE TYPE "MessageStatus" AS ENUM ('Queued', 'Sending', 'Sent', 'Failed', 'Del -- CreateEnum CREATE TYPE "CallStatus" AS ENUM ('Queued', 'Ringing', 'InProgress', 'Completed', 'Busy', 'Failed', 'NoAnswer', 'Canceled'); +-- CreateTable +CREATE TABLE "TwilioAccount" ( + "subAccountSid" TEXT NOT NULL, + "createdAt" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMPTZ(6) NOT NULL, + "subAccountAuthToken" TEXT NOT NULL, + "accountSid" TEXT NOT NULL, + "accountAuthToken" TEXT NOT NULL, + "twimlAppSid" TEXT, + "organizationId" TEXT NOT NULL, + + CONSTRAINT "TwilioAccount_pkey" PRIMARY KEY ("subAccountSid") +); + -- CreateTable CREATE TABLE "Organization" ( "id" TEXT NOT NULL, "createdAt" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMPTZ NOT NULL, - "twilioAccountSid" TEXT, - "twilioSubAccountSid" TEXT, CONSTRAINT "Organization_pkey" PRIMARY KEY ("id") ); @@ -142,6 +154,9 @@ CREATE TABLE "PhoneNumber" ( CONSTRAINT "PhoneNumber_pkey" PRIMARY KEY ("id") ); +-- CreateIndex +CREATE UNIQUE INDEX "TwilioAccount_organizationId_key" ON "TwilioAccount"("organizationId"); + -- CreateIndex CREATE UNIQUE INDEX "Subscription_paddleSubscriptionId_key" ON "Subscription"("paddleSubscriptionId"); @@ -160,6 +175,10 @@ CREATE UNIQUE INDEX "Token_hashedToken_type_key" ON "Token"("hashedToken", "type -- CreateIndex CREATE UNIQUE INDEX "PhoneNumber_organizationId_isCurrent_key" ON "PhoneNumber"("organizationId", "isCurrent") WHERE ("isCurrent" = true); +-- AddForeignKey +ALTER TABLE "TwilioAccount" ADD CONSTRAINT "TwilioAccount_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + + -- AddForeignKey ALTER TABLE "Subscription" ADD CONSTRAINT "Subscription_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 66b1110..9df1e8a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1,185 +1,197 @@ generator client { - provider = "prisma-client-js" + provider = "prisma-client-js" } datasource db { - provider = "postgresql" - url = env("DATABASE_URL") + provider = "postgresql" + url = env("DATABASE_URL") +} + +model TwilioAccount { + subAccountSid String @id + createdAt DateTime @default(now()) @db.Timestamptz(6) + updatedAt DateTime @updatedAt @db.Timestamptz(6) + subAccountAuthToken String + accountSid String + accountAuthToken String + twimlAppSid String? + + organizationId String @unique + organization Organization @relation(fields: [organizationId], references: [id]) } model Organization { - id String @id @default(cuid()) - createdAt DateTime @default(now()) @db.Timestamptz(6) - updatedAt DateTime @updatedAt @db.Timestamptz(6) - twilioAccountSid String? - twilioSubAccountSid String? + id String @id @default(cuid()) + createdAt DateTime @default(now()) @db.Timestamptz(6) + updatedAt DateTime @updatedAt @db.Timestamptz(6) - memberships Membership[] - phoneNumbers PhoneNumber[] - subscriptions Subscription[] // many subscriptions to keep a history + twilioAccount TwilioAccount? + memberships Membership[] + phoneNumbers PhoneNumber[] + subscriptions Subscription[] // many subscriptions to keep a history } model Subscription { - createdAt DateTime @default(now()) @db.Timestamptz(6) - updatedAt DateTime @updatedAt @db.Timestamptz(6) - paddleSubscriptionId Int @id @unique - paddlePlanId Int - paddleCheckoutId String - status SubscriptionStatus - updateUrl String - cancelUrl String - currency String - unitPrice Float - nextBillDate DateTime @db.Date - lastEventTime DateTime @db.Timestamp(6) - cancellationEffectiveDate DateTime? @db.Date - organizationId String - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) @db.Timestamptz(6) + updatedAt DateTime @updatedAt @db.Timestamptz(6) + paddleSubscriptionId Int @id @unique + paddlePlanId Int + paddleCheckoutId String + status SubscriptionStatus + updateUrl String + cancelUrl String + currency String + unitPrice Float + nextBillDate DateTime @db.Date + lastEventTime DateTime @db.Timestamp(6) + cancellationEffectiveDate DateTime? @db.Date + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) } model Membership { - id String @id @default(cuid()) - role MembershipRole - organizationId String - userId String? - invitedEmail String? - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) - user User? @relation(fields: [userId], references: [id], onDelete: Cascade) - invitationToken Token? + id String @id @default(cuid()) + role MembershipRole + organizationId String + userId String? + invitedEmail String? + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + invitationToken Token? - @@unique([organizationId, invitedEmail]) + @@unique([organizationId, invitedEmail]) } model User { - id String @id @default(cuid()) - createdAt DateTime @default(now()) @db.Timestamptz(6) - updatedAt DateTime @updatedAt @db.Timestamptz(6) - fullName String - email String @unique - hashedPassword String? - role GlobalRole @default(CUSTOMER) - memberships Membership[] - sessions Session[] - tokens Token[] + id String @id @default(cuid()) + createdAt DateTime @default(now()) @db.Timestamptz(6) + updatedAt DateTime @updatedAt @db.Timestamptz(6) + fullName String + email String @unique + hashedPassword String? + role GlobalRole @default(CUSTOMER) + memberships Membership[] + sessions Session[] + tokens Token[] } model Session { - id String @id @default(cuid()) - createdAt DateTime @default(now()) @db.Timestamptz(6) - updatedAt DateTime @updatedAt @db.Timestamptz(6) - expiresAt DateTime? @db.Timestamptz(6) - data String - userId String? - user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + createdAt DateTime @default(now()) @db.Timestamptz(6) + updatedAt DateTime @updatedAt @db.Timestamptz(6) + expiresAt DateTime? @db.Timestamptz(6) + data String + userId String? + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) } model Token { - id String @id @default(cuid()) - createdAt DateTime @default(now()) @db.Timestamptz(6) - updatedAt DateTime @updatedAt @db.Timestamptz(6) - hashedToken String - type TokenType - expiresAt DateTime @db.Timestamptz(6) - sentTo String - userId String - membershipId String @unique - membership Membership @relation(fields: [membershipId], references: [id], onDelete: Cascade) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + createdAt DateTime @default(now()) @db.Timestamptz(6) + updatedAt DateTime @updatedAt @db.Timestamptz(6) + hashedToken String + type TokenType + expiresAt DateTime @db.Timestamptz(6) + sentTo String + userId String + membershipId String @unique + membership Membership @relation(fields: [membershipId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) - @@unique([hashedToken, type]) + @@unique([hashedToken, type]) } model Message { - id String @id - sentAt DateTime @db.Timestamptz(6) - content String - from String - to String - direction Direction - status MessageStatus - phoneNumberId String - phoneNumber PhoneNumber @relation(fields: [phoneNumberId], references: [id], onDelete: Cascade) + id String @id + sentAt DateTime @db.Timestamptz(6) + content String + from String + to String + direction Direction + status MessageStatus + phoneNumberId String + phoneNumber PhoneNumber @relation(fields: [phoneNumberId], references: [id], onDelete: Cascade) } model PhoneCall { - id String @id @unique - createdAt DateTime @default(now()) @db.Timestamptz(6) - from String - to String - status CallStatus - direction Direction - duration String - phoneNumberId String - phoneNumber PhoneNumber @relation(fields: [phoneNumberId], references: [id], onDelete: Cascade) + id String @id + createdAt DateTime @default(now()) @db.Timestamptz(6) + from String + to String + status CallStatus + direction Direction + duration String + phoneNumberId String + phoneNumber PhoneNumber @relation(fields: [phoneNumberId], references: [id], onDelete: Cascade) } model PhoneNumber { - 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) - messages Message[] - phoneCalls PhoneCall[] + 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) + messages Message[] + phoneCalls PhoneCall[] - @@unique([organizationId, isCurrent]) + @@unique([organizationId, isCurrent]) } enum SubscriptionStatus { - active - trialing - past_due - paused - deleted + active + trialing + past_due + paused + deleted } enum MembershipRole { - OWNER - USER + OWNER + USER } enum GlobalRole { - SUPERADMIN - CUSTOMER + SUPERADMIN + CUSTOMER } enum TokenType { - RESET_PASSWORD - INVITE_MEMBER + RESET_PASSWORD + INVITE_MEMBER } enum Direction { - Inbound - Outbound + Inbound + Outbound } enum MessageStatus { - Queued - Sending - Sent - Failed - Delivered - Undelivered - Receiving - Received - Accepted - Scheduled - Read - PartiallyDelivered - Canceled - Error + Queued + Sending + Sent + Failed + Delivered + Undelivered + Receiving + Received + Accepted + Scheduled + Read + PartiallyDelivered + Canceled + Error } enum CallStatus { - Queued - Ringing - InProgress - Completed - Busy - Failed - NoAnswer - Canceled + Queued + Ringing + InProgress + Completed + Busy + Failed + NoAnswer + Canceled }