store twilio stuff in TwilioAccount table and remodel session data

This commit is contained in:
m5r 2022-05-21 21:33:23 +02:00
parent 19a35bac92
commit 6684dcc0e5
23 changed files with 411 additions and 365 deletions

View File

@ -8,19 +8,14 @@ import getTwilioClient, { translateMessageDirection, translateMessageStatus } fr
export type NewMessageActionData = {}; export type NewMessageActionData = {};
const action: ActionFunction = async ({ params, request }) => { const action: ActionFunction = async ({ params, request }) => {
const user = await requireLoggedIn(request); const { phoneNumber, twilioAccount } = await requireLoggedIn(request);
const organization = user.organizations[0]; if (!twilioAccount) {
const phoneNumber = await db.phoneNumber.findUnique({ throw new Error("unreachable");
where: { organizationId_isCurrent: { organizationId: user.organizations[0].id, isCurrent: true } }, }
});
const recipient = decodeURIComponent(params.recipient ?? ""); const recipient = decodeURIComponent(params.recipient ?? "");
const formData = Object.fromEntries(await request.formData()); const formData = Object.fromEntries(await request.formData());
const twilioClient = getTwilioClient(twilioAccount);
const { twilioAccountSid, twilioSubAccountSid } = organization;
// const twilioClient = getTwilioClient({ twilioSubAccountSid, twilioAccountSid });
const twilioClient = getTwilioClient({ twilioSubAccountSid: twilioAccountSid, twilioAccountSid });
try { try {
console.log({ twilioAccountSid, twilioSubAccountSid });
console.log({ console.log({
body: formData.content.toString(), body: formData.content.toString(),
to: recipient, to: recipient,

View File

@ -10,7 +10,7 @@ import { type ConversationLoaderData } from "~/routes/__app/messages.$recipient"
import useSession from "~/features/core/hooks/use-session"; import useSession from "~/features/core/hooks/use-session";
export default function Conversation() { export default function Conversation() {
const { currentPhoneNumber } = useSession(); const { phoneNumber } = useSession();
const params = useParams<{ recipient: string }>(); const params = useParams<{ recipient: string }>();
const recipient = decodeURIComponent(params.recipient ?? ""); const recipient = decodeURIComponent(params.recipient ?? "");
const { conversation } = useLoaderData<ConversationLoaderData>(); const { conversation } = useLoaderData<ConversationLoaderData>();
@ -21,15 +21,15 @@ export default function Conversation() {
if (transition.submission) { if (transition.submission) {
messages.push({ messages.push({
id: "temp", id: "temp",
phoneNumberId: currentPhoneNumber.id, phoneNumberId: phoneNumber!.id,
from: currentPhoneNumber.number, from: phoneNumber!.number,
to: recipient, to: recipient,
sentAt: new Date(), sentAt: new Date(),
direction: Direction.Outbound, direction: Direction.Outbound,
status: "Queued", status: "Queued",
content: transition.submission.formData.get("content")!.toString() content: transition.submission.formData.get("content")!.toString(),
}) });
} }
useEffect(() => { useEffect(() => {
@ -91,7 +91,7 @@ export default function Conversation() {
})} })}
</ul> </ul>
</div> </div>
<NewMessageArea /> <NewMessageArea recipient={recipient} />
</> </>
); );
} }

View File

@ -20,8 +20,8 @@ export default function NewMessageBottomSheet() {
onClose={() => setIsOpen(false)} onClose={() => setIsOpen(false)}
snapPoints={[0.5]} snapPoints={[0.5]}
> >
<BottomSheet.Container> <BottomSheet.Container onViewportBoxUpdate={null}>
<BottomSheet.Header> <BottomSheet.Header onViewportBoxUpdate={null}>
<div className="w-full flex items-center justify-center p-4 text-black relative"> <div className="w-full flex items-center justify-center p-4 text-black relative">
<span className="font-semibold text-base">New Message</span> <span className="font-semibold text-base">New Message</span>
@ -30,7 +30,7 @@ export default function NewMessageBottomSheet() {
</button> </button>
</div> </div>
</BottomSheet.Header> </BottomSheet.Header>
<BottomSheet.Content> <BottomSheet.Content onViewportBoxUpdate={null}>
<main className="flex flex-col h-full overflow-hidden"> <main className="flex flex-col h-full overflow-hidden">
<div className="flex items-center p-4 border-t border-b"> <div className="flex items-center p-4 border-t border-b">
<span className="mr-4 text-[#333]">To:</span> <span className="mr-4 text-[#333]">To:</span>
@ -48,7 +48,7 @@ export default function NewMessageBottomSheet() {
</BottomSheet.Content> </BottomSheet.Content>
</BottomSheet.Container> </BottomSheet.Container>
<BottomSheet.Backdrop onTap={() => setIsOpen(false)} /> <BottomSheet.Backdrop onViewportBoxUpdate={null} onTap={() => setIsOpen(false)} />
</BottomSheet> </BottomSheet>
); );
} }

View File

@ -1,10 +1,10 @@
import type { LoaderFunction } from "@remix-run/node"; import type { LoaderFunction } from "@remix-run/node";
import { json } from "superjson-remix"; import { json } from "superjson-remix";
import { parsePhoneNumber } from "awesome-phonenumber"; 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 db from "~/utils/db.server";
import { requireLoggedIn } from "~/utils/auth.server"; import { requireLoggedIn, type SessionData } from "~/utils/auth.server";
export type MessagesLoaderData = { export type MessagesLoaderData = {
user: { hasPhoneNumber: boolean }; user: { hasPhoneNumber: boolean };
@ -18,56 +18,22 @@ type Conversation = {
}; };
const loader: LoaderFunction = async ({ request }) => { const loader: LoaderFunction = async ({ request }) => {
const { id, organizations } = await requireLoggedIn(request); const sessionData = 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();
return json<MessagesLoaderData>({ return json<MessagesLoaderData>({
user: { hasPhoneNumber: Boolean(phoneNumber) }, user: { hasPhoneNumber: Boolean(sessionData.phoneNumber) },
conversations, conversations: await getConversations(sessionData.phoneNumber),
}); });
};
export default loader;
async function getConversations(sessionPhoneNumber: SessionData["phoneNumber"]) {
if (!sessionPhoneNumber) {
return;
}
async function getConversations() {
const organizationId = organizations[0].id;
const phoneNumber = await db.phoneNumber.findUnique({ const phoneNumber = await db.phoneNumber.findUnique({
where: { organizationId_isCurrent: { organizationId, isCurrent: true } }, where: { id: sessionPhoneNumber.id },
}); });
if (!phoneNumber || phoneNumber.isFetchingMessages) { if (!phoneNumber || phoneNumber.isFetchingMessages) {
return; return;
@ -106,7 +72,4 @@ const loader: LoaderFunction = async ({ request }) => {
} }
return conversations; return conversations;
} }
};
export default loader;

View File

@ -35,7 +35,9 @@ const action: ActionFunction = async ({ request }) => {
export default action; export default action;
async function deleteUser(request: Request) { async function deleteUser(request: Request) {
const { id } = await requireLoggedIn(request); const {
user: { id },
} = await requireLoggedIn(request);
await db.user.update({ await db.user.update({
where: { id }, 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 user = await db.user.findUnique({ where: { id } });
const { currentPassword, newPassword } = validation.data; const { currentPassword, newPassword } = validation.data;
const verificationResult = await verifyPassword(user!.hashedPassword!, currentPassword); 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; const { email, fullName } = validation.data;
await db.user.update({ await db.user.update({
where: { id: user.id }, where: { id: user.id },

View File

@ -5,14 +5,14 @@ import { z } from "zod";
import db from "~/utils/db.server"; import db from "~/utils/db.server";
import { type FormError, validate } from "~/utils/validation.server"; import { type FormError, validate } from "~/utils/validation.server";
import { requireLoggedIn } from "~/utils/auth.server"; import { requireLoggedIn } from "~/utils/auth.server";
import setTwilioWebhooksQueue from "~/queues/set-twilio-webhooks.server";
type SetPhoneNumberFailureActionData = { errors: FormError<typeof bodySchema>; submitted?: never }; type SetPhoneNumberFailureActionData = { errors: FormError<typeof bodySchema>; submitted?: never };
type SetPhoneNumberSuccessfulActionData = { errors?: never; submitted: true }; type SetPhoneNumberSuccessfulActionData = { errors?: never; submitted: true };
export type SetPhoneNumberActionData = SetPhoneNumberFailureActionData | SetPhoneNumberSuccessfulActionData; export type SetPhoneNumberActionData = SetPhoneNumberFailureActionData | SetPhoneNumberSuccessfulActionData;
const action: ActionFunction = async ({ request }) => { const action: ActionFunction = async ({ request }) => {
const { organizations } = await requireLoggedIn(request); const { organization } = await requireLoggedIn(request);
const organization = organizations[0];
const formData = Object.fromEntries(await request.formData()); const formData = Object.fromEntries(await request.formData());
const validation = validate(bodySchema, formData); const validation = validate(bodySchema, formData);
if (validation.errors) { if (validation.errors) {
@ -35,6 +35,11 @@ const action: ActionFunction = async ({ request }) => {
where: { id: validation.data.phoneNumberSid }, where: { id: validation.data.phoneNumberSid },
data: { isCurrent: true }, 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<SetPhoneNumberActionData>({ submitted: true }); return json<SetPhoneNumberActionData>({ submitted: true });
}; };

View File

@ -8,7 +8,7 @@ import Button from "../button";
import SettingsSection from "../settings-section"; import SettingsSection from "../settings-section";
const ProfileInformations: FunctionComponent = () => { const ProfileInformations: FunctionComponent = () => {
const user = useSession(); const { user } = useSession();
const transition = useTransition(); const transition = useTransition();
const actionData = useActionData<UpdateUserActionData>()?.updateUser; const actionData = useActionData<UpdateUserActionData>()?.updateUser;

View File

@ -10,7 +10,7 @@ import type { SetPhoneNumberActionData } from "~/features/settings/actions/phone
export default function PhoneNumberForm() { export default function PhoneNumberForm() {
const transition = useTransition(); const transition = useTransition();
const actionData = useActionData<SetPhoneNumberActionData>(); const actionData = useActionData<SetPhoneNumberActionData>();
const { currentOrganization } = useSession(); const { twilioAccount } = useSession();
const availablePhoneNumbers = useLoaderData<PhoneSettingsLoaderData>().phoneNumbers; const availablePhoneNumbers = useLoaderData<PhoneSettingsLoaderData>().phoneNumbers;
const isSubmitting = transition.state === "submitting"; const isSubmitting = transition.state === "submitting";
@ -18,8 +18,8 @@ export default function PhoneNumberForm() {
const errors = actionData?.errors; const errors = actionData?.errors;
const topErrorMessage = errors?.general ?? errors?.phoneNumberSid; const topErrorMessage = errors?.general ?? errors?.phoneNumberSid;
const isError = typeof topErrorMessage !== "undefined"; const isError = typeof topErrorMessage !== "undefined";
const currentPhoneNumber = availablePhoneNumbers.find(phoneNumber => phoneNumber.isCurrent === true); const currentPhoneNumber = availablePhoneNumbers.find((phoneNumber) => phoneNumber.isCurrent === true);
const hasFilledTwilioCredentials = Boolean(currentOrganization.twilioAccountSid) const hasFilledTwilioCredentials = twilioAccount !== null;
if (!hasFilledTwilioCredentials) { if (!hasFilledTwilioCredentials) {
return null; return null;

View File

@ -6,7 +6,7 @@ import SettingsSection from "../settings-section";
import useSession from "~/features/core/hooks/use-session"; import useSession from "~/features/core/hooks/use-session";
export default function TwilioConnect() { export default function TwilioConnect() {
const { currentOrganization } = useSession(); const { twilioAccount } = useSession();
const [isHelpModalOpen, setIsHelpModalOpen] = useState(false); const [isHelpModalOpen, setIsHelpModalOpen] = useState(false);
return ( return (
@ -20,7 +20,7 @@ export default function TwilioConnect() {
Shellphone needs to connect to your Twilio account to securely use your phone numbers. Shellphone needs to connect to your Twilio account to securely use your phone numbers.
</article> </article>
{currentOrganization.twilioAccountSid === null ? ( {twilioAccount === null ? (
<a <a
href="https://www.twilio.com/authorize/CN01675d385a9ee79e6aa58adf54abe3b3" href="https://www.twilio.com/authorize/CN01675d385a9ee79e6aa58adf54abe3b3"
rel="noopener noreferrer" rel="noopener noreferrer"

View File

@ -12,15 +12,24 @@ export default Queue<Payload>("fetch messages", async ({ data }) => {
const { phoneNumberId } = data; const { phoneNumberId } = data;
const phoneNumber = await db.phoneNumber.findUnique({ const phoneNumber = await db.phoneNumber.findUnique({
where: { id: phoneNumberId }, where: { id: phoneNumberId },
include: { organization: true }, include: {
organization: {
select: { twilioAccount: true },
},
},
}); });
if (!phoneNumber) { if (!phoneNumber) {
logger.warn(`No phone number found with id=${phoneNumberId}`); logger.warn(`No phone number found with id=${phoneNumberId}`);
return; return;
} }
const organization = phoneNumber.organization; const twilioAccount = phoneNumber.organization.twilioAccount;
const twilioClient = getTwilioClient(organization); 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([ const [sent, received] = await Promise.all([
twilioClient.messages.list({ from: phoneNumber.number }), twilioClient.messages.list({ from: phoneNumber.number }),
twilioClient.messages.list({ to: phoneNumber.number }), twilioClient.messages.list({ to: phoneNumber.number }),

View File

@ -12,15 +12,24 @@ export default Queue<Payload>("fetch phone calls", async ({ data }) => {
const { phoneNumberId } = data; const { phoneNumberId } = data;
const phoneNumber = await db.phoneNumber.findUnique({ const phoneNumber = await db.phoneNumber.findUnique({
where: { id: phoneNumberId }, where: { id: phoneNumberId },
include: { organization: true }, include: {
organization: {
select: { twilioAccount: true },
},
},
}); });
if (!phoneNumber) { if (!phoneNumber) {
logger.warn(`No phone number found with id=${phoneNumberId}`); logger.warn(`No phone number found with id=${phoneNumberId}`);
return; return;
} }
const organization = phoneNumber.organization; const twilioAccount = phoneNumber.organization.twilioAccount;
const twilioClient = getTwilioClient(organization); 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([ const [callsSent, callsReceived] = await Promise.all([
twilioClient.calls.list({ from: phoneNumber.number }), twilioClient.calls.list({ from: phoneNumber.number }),
twilioClient.calls.list({ to: phoneNumber.number }), twilioClient.calls.list({ to: phoneNumber.number }),

View File

@ -1,41 +1,20 @@
import { type LoaderFunction, json } from "@remix-run/node"; import { type LoaderFunction, json } from "@remix-run/node";
import { Outlet, useCatch, useMatches } from "@remix-run/react"; 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 Footer from "~/features/core/components/footer";
import db from "~/utils/db.server";
export type AppLoaderData = SessionData; export type AppLoaderData = SessionData;
export const loader: LoaderFunction = async ({ request }) => { export const loader: LoaderFunction = async ({ request }) => {
const user = await requireLoggedIn(request); const sessionData = 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];
return json<AppLoaderData>({ ...user, currentOrganization, currentPhoneNumber }); return json<AppLoaderData>(sessionData);
}; };
export default function __App() { export default function __App() {
const matches = useMatches(); const matches = useMatches();
const hideFooter = matches.some(match => match.handle?.hideFooter === true); const hideFooter = matches.some((match) => match.handle?.hideFooter === true);
return ( return (
<div className="h-full w-full overflow-hidden fixed bg-gray-100"> <div className="h-full w-full overflow-hidden fixed bg-gray-100">

View File

@ -26,10 +26,13 @@ export type PhoneCallsLoaderData = {
}; };
export const loader: LoaderFunction = async ({ request }) => { export const loader: LoaderFunction = async ({ request }) => {
const { organizations } = await requireLoggedIn(request); const sessionData = await requireLoggedIn(request);
const organizationId = organizations[0].id; if (!sessionData.phoneNumber) {
throw new Error("unreachable");
}
const phoneNumber = await db.phoneNumber.findUnique({ const phoneNumber = await db.phoneNumber.findUnique({
where: { organizationId_isCurrent: { organizationId, isCurrent: true } }, where: { id: sessionData.phoneNumber.id },
}); });
if (!phoneNumber || phoneNumber.isFetchingCalls) { if (!phoneNumber || phoneNumber.isFetchingCalls) {
return json<PhoneCallsLoaderData>({ return json<PhoneCallsLoaderData>({

View File

@ -13,7 +13,7 @@ import useKeyPress from "~/features/keypad/hooks/use-key-press";
import KeypadErrorModal from "~/features/keypad/components/keypad-error-modal"; import KeypadErrorModal from "~/features/keypad/components/keypad-error-modal";
import InactiveSubscription from "~/features/core/components/inactive-subscription"; import InactiveSubscription from "~/features/core/components/inactive-subscription";
export default function SettingsLayout() { export default function KeypadPage() {
const { hasFilledTwilioCredentials, hasPhoneNumber, hasOngoingSubscription } = { const { hasFilledTwilioCredentials, hasPhoneNumber, hasOngoingSubscription } = {
hasFilledTwilioCredentials: false, hasFilledTwilioCredentials: false,
hasPhoneNumber: false, hasPhoneNumber: false,

View File

@ -1,4 +1,3 @@
import { Suspense } from "react";
import type { LoaderFunction, MetaFunction } from "@remix-run/node"; import type { LoaderFunction, MetaFunction } from "@remix-run/node";
import { Link, useNavigate, useParams } from "@remix-run/react"; import { Link, useNavigate, useParams } from "@remix-run/react";
import { json, useLoaderData } from "superjson-remix"; import { json, useLoaderData } from "superjson-remix";
@ -35,14 +34,14 @@ export type ConversationLoaderData = {
}; };
export const loader: LoaderFunction = async ({ request, params }) => { export const loader: LoaderFunction = async ({ request, params }) => {
const { organizations } = await requireLoggedIn(request); const { organization } = await requireLoggedIn(request);
const recipient = decodeURIComponent(params.recipient ?? ""); const recipient = decodeURIComponent(params.recipient ?? "");
const conversation = await getConversation(recipient); const conversation = await getConversation(recipient);
return json<ConversationLoaderData>({ conversation }); return json<ConversationLoaderData>({ conversation });
async function getConversation(recipient: string): Promise<ConversationType> { async function getConversation(recipient: string): Promise<ConversationType> {
const organizationId = organizations[0].id; const organizationId = organization.id;
const phoneNumber = await db.phoneNumber.findUnique({ const phoneNumber = await db.phoneNumber.findUnique({
where: { organizationId_isCurrent: { organizationId, isCurrent: true } }, where: { organizationId_isCurrent: { organizationId, isCurrent: true } },
}); });

View File

@ -13,9 +13,8 @@ export type PhoneSettingsLoaderData = {
}; };
export const loader: LoaderFunction = async ({ request }) => { export const loader: LoaderFunction = async ({ request }) => {
const { organizations } = await requireLoggedIn(request); const { organization, twilioAccount } = await requireLoggedIn(request);
const organization = organizations[0]; if (!twilioAccount) {
if (!organization.twilioAccountSid) {
logger.warn("Twilio account is not connected"); logger.warn("Twilio account is not connected");
return json<PhoneSettingsLoaderData>({ phoneNumbers: [] }); return json<PhoneSettingsLoaderData>({ phoneNumbers: [] });
} }

View File

@ -8,10 +8,10 @@ import serverConfig from "~/config/config.server";
import getTwilioClient from "~/utils/twilio.server"; import getTwilioClient from "~/utils/twilio.server";
import fetchPhoneCallsQueue from "~/queues/fetch-phone-calls.server"; import fetchPhoneCallsQueue from "~/queues/fetch-phone-calls.server";
import fetchMessagesQueue from "~/queues/fetch-messages.server"; import fetchMessagesQueue from "~/queues/fetch-messages.server";
import { encrypt } from "~/utils/encryption";
export const loader: LoaderFunction = async ({ request }) => { export const loader: LoaderFunction = async ({ request }) => {
const user = await requireLoggedIn(request); const { organization } = await requireLoggedIn(request);
const organization = user.organizations[0];
const url = new URL(request.url); const url = new URL(request.url);
const twilioSubAccountSid = url.searchParams.get("AccountSid"); const twilioSubAccountSid = url.searchParams.get("AccountSid");
if (!twilioSubAccountSid) { if (!twilioSubAccountSid) {
@ -20,13 +20,21 @@ export const loader: LoaderFunction = async ({ request }) => {
let twilioClient = twilio(twilioSubAccountSid, serverConfig.twilio.authToken); let twilioClient = twilio(twilioSubAccountSid, serverConfig.twilio.authToken);
const twilioSubAccount = await twilioClient.api.accounts(twilioSubAccountSid).fetch(); const twilioSubAccount = await twilioClient.api.accounts(twilioSubAccountSid).fetch();
const twilioAccountSid = twilioSubAccount.ownerAccountSid; const twilioMainAccountSid = twilioSubAccount.ownerAccountSid;
await db.organization.update({ const twilioMainAccount = await twilioClient.api.accounts(twilioMainAccountSid).fetch();
where: { id: organization.id }, console.log("twilioSubAccount", twilioSubAccount);
data: { twilioSubAccountSid, twilioAccountSid }, 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(); const phoneNumbers = await twilioClient.incomingPhoneNumbers.list();
await Promise.all( await Promise.all(
phoneNumbers.map(async (phoneNumber) => { phoneNumbers.map(async (phoneNumber) => {

View File

@ -1,26 +1,31 @@
import { redirect, type Session } from "@remix-run/node"; import { redirect, type Session } from "@remix-run/node";
import type { FormStrategyVerifyParams } from "remix-auth-form"; import type { FormStrategyVerifyParams } from "remix-auth-form";
import SecurePassword from "secure-password"; 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 db from "./db.server";
import logger from "./logger.server"; import logger from "./logger.server";
import authenticator from "./authenticator.server"; import authenticator from "./authenticator.server";
import { AuthenticationError } from "./errors"; import { AuthenticationError, NotFoundError } from "./errors";
import { commitSession, destroySession, getSession } from "./session.server"; import { commitSession, destroySession, getSession } from "./session.server";
export type SessionOrganization = Pick<Organization, "id" | "twilioSubAccountSid" | "twilioAccountSid"> & { type SessionTwilioAccount = Pick<
role: MembershipRole; TwilioAccount,
"accountSid" | "accountAuthToken" | "subAccountSid" | "subAccountAuthToken" | "twimlAppSid"
>;
type SessionOrganization = Pick<Organization, "id"> & { role: MembershipRole };
type SessionPhoneNumber = Pick<PhoneNumber, "id" | "number">;
export type SessionUser = Pick<User, "id" | "role" | "email" | "fullName">;
export type SessionData = {
user: SessionUser;
organization: SessionOrganization;
phoneNumber: SessionPhoneNumber | null;
twilioAccount: SessionTwilioAccount | null;
}; };
export type SessionPhoneNumber = Pick<PhoneNumber, "id" | "number">;
export type SessionUser = Omit<User, "hashedPassword"> & {
organizations: SessionOrganization[];
};
export type SessionData = SessionUser & { currentOrganization: SessionOrganization; currentPhoneNumber: SessionPhoneNumber };
const SP = new SecurePassword(); const SP = new SecurePassword();
export async function login({ form }: FormStrategyVerifyParams): Promise<SessionUser> { export async function login({ form }: FormStrategyVerifyParams): Promise<SessionData> {
const email = form.get("email"); const email = form.get("email");
const password = form.get("password"); const password = form.get("password");
const isEmailValid = typeof email === "string" && email.length > 0; const isEmailValid = typeof email === "string" && email.length > 0;
@ -36,21 +41,8 @@ export async function login({ form }: FormStrategyVerifyParams): Promise<Session
throw new AuthenticationError("Password is required"); throw new AuthenticationError("Password is required");
} }
const user = await db.user.findUnique({ const user = await db.user.findUnique({ where: { email: email.toLowerCase() } });
where: { email: email.toLowerCase() },
include: {
memberships: {
select: {
organization: {
select: { id: true, twilioSubAccountSid: true, twilioAccountSid: true },
},
role: true,
},
},
},
});
if (!user || !user.hashedPassword) { if (!user || !user.hashedPassword) {
logger.warn(`User with email=${email.toLowerCase()} not found`);
throw new AuthenticationError("Incorrect password"); throw new AuthenticationError("Incorrect password");
} }
@ -67,16 +59,15 @@ export async function login({ form }: FormStrategyVerifyParams): Promise<Session
throw new AuthenticationError("Incorrect password"); throw new AuthenticationError("Incorrect password");
} }
const { hashedPassword, memberships, ...rest } = user; try {
const organizations = memberships.map((membership) => ({ return await buildSessionData(user.id);
...membership.organization, } catch (error: any) {
role: membership.role, if (error instanceof AuthenticationError) {
})); throw error;
}
return { throw new AuthenticationError("Incorrect password");
...rest, }
organizations,
};
} }
export async function verifyPassword(hashedPassword: string, password: string) { export async function verifyPassword(hashedPassword: string, password: string) {
@ -114,9 +105,10 @@ export async function authenticate({
method: "post", method: "post",
headers: request.headers, 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); const session = await getSession(request);
session.set(authenticator.sessionKey, user); session.set(authenticator.sessionKey, sessionData);
const redirectTo = successRedirect ?? "/messages"; const redirectTo = successRedirect ?? "/messages";
return redirect(redirectTo, { return redirect(redirectTo, {
headers: { "Set-Cookie": await commitSession(session) }, headers: { "Set-Cookie": await commitSession(session) },
@ -161,23 +153,50 @@ function buildRedirectTo(url: URL) {
} }
export async function refreshSessionData(request: Request) { 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<SessionData> {
const user = await db.user.findUnique({ const user = await db.user.findUnique({
where: { id }, where: { id },
include: { include: {
memberships: { memberships: {
select: { select: {
organization: { 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, role: true,
}, },
}, },
}, },
}); });
if (!user || !user.hashedPassword) { if (!user) {
logger.warn(`User with id=${id} not found`); 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; const { hashedPassword, memberships, ...rest } = user;
@ -185,12 +204,14 @@ export async function refreshSessionData(request: Request) {
...membership.organization, ...membership.organization,
role: membership.role, role: membership.role,
})); }));
const sessionUser: SessionUser = { const { twilioAccount, ...organization } = organizations[0];
...rest, const phoneNumber = await db.phoneNumber.findUnique({
organizations, 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 };
} }

View File

@ -2,9 +2,9 @@ import { Authenticator } from "remix-auth";
import { FormStrategy } from "remix-auth-form"; import { FormStrategy } from "remix-auth-form";
import { sessionStorage } from "./session.server"; import { sessionStorage } from "./session.server";
import { type SessionUser, login } from "./auth.server"; import { type SessionData, login } from "./auth.server";
const authenticator = new Authenticator<SessionUser>(sessionStorage); const authenticator = new Authenticator<SessionData>(sessionStorage);
authenticator.use(new FormStrategy(login), "email-password"); authenticator.use(new FormStrategy(login), "email-password");

View File

@ -3,6 +3,8 @@ import { type Session, type SessionIdStorageStrategy, createSessionStorage } fro
import serverConfig from "~/config/config.server"; import serverConfig from "~/config/config.server";
import db from "./db.server"; import db from "./db.server";
import logger from "./logger.server"; import logger from "./logger.server";
import authenticator from "~/utils/authenticator.server";
import type { SessionData } from "~/utils/auth.server";
const SECOND = 1; const SECOND = 1;
const MINUTE = 60 * SECOND; const MINUTE = 60 * SECOND;
@ -32,8 +34,9 @@ function createDatabaseSessionStorage({ cookie }: Pick<SessionIdStorageStrategy,
cookie, cookie,
async createData(sessionData, expiresAt) { async createData(sessionData, expiresAt) {
let user; let user;
if (sessionData.user) { const sessionAuthData: SessionData = sessionData[authenticator.sessionKey];
user = { connect: { id: sessionData.user.id } }; if (sessionAuthData) {
user = { connect: { id: sessionAuthData.user.id } };
} }
const { id } = await db.session.create({ const { id } = await db.session.create({
data: { data: {

View File

@ -1,22 +1,40 @@
import twilio from "twilio"; import twilio from "twilio";
import type { MessageInstance } from "twilio/lib/rest/api/v2010/account/message"; import type { MessageInstance } from "twilio/lib/rest/api/v2010/account/message";
import type { CallInstance } from "twilio/lib/rest/api/v2010/account/call"; import type { CallInstance } from "twilio/lib/rest/api/v2010/account/call";
import { type Organization, CallStatus, Direction, MessageStatus } from "@prisma/client"; import { type TwilioAccount, CallStatus, Direction, MessageStatus } from "@prisma/client";
import serverConfig from "~/config/config.server"; import serverConfig from "~/config/config.server";
type MinimalOrganization = Pick<Organization, "twilioSubAccountSid" | "twilioAccountSid">; export default function getTwilioClient({
accountSid,
export default function getTwilioClient({ twilioAccountSid, twilioSubAccountSid }: MinimalOrganization): twilio.Twilio { subAccountSid,
if (!twilioSubAccountSid || !twilioAccountSid) { subAccountAuthToken,
}: Pick<TwilioAccount, "accountSid" | "subAccountSid"> &
Partial<Pick<TwilioAccount, "subAccountAuthToken">>): twilio.Twilio {
if (!subAccountSid || !accountSid) {
throw new Error("unreachable"); throw new Error("unreachable");
} }
return twilio(twilioSubAccountSid, serverConfig.twilio.authToken, { return twilio(subAccountSid, serverConfig.twilio.authToken, {
accountSid: twilioAccountSid, 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 { export function translateMessageStatus(status: MessageInstance["status"]): MessageStatus {
switch (status) { switch (status) {
case "accepted": case "accepted":

View File

@ -19,13 +19,25 @@ CREATE TYPE "MessageStatus" AS ENUM ('Queued', 'Sending', 'Sent', 'Failed', 'Del
-- CreateEnum -- CreateEnum
CREATE TYPE "CallStatus" AS ENUM ('Queued', 'Ringing', 'InProgress', 'Completed', 'Busy', 'Failed', 'NoAnswer', 'Canceled'); 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 -- CreateTable
CREATE TABLE "Organization" ( CREATE TABLE "Organization" (
"id" TEXT NOT NULL, "id" TEXT NOT NULL,
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, "createdAt" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ NOT NULL, "updatedAt" TIMESTAMPTZ NOT NULL,
"twilioAccountSid" TEXT,
"twilioSubAccountSid" TEXT,
CONSTRAINT "Organization_pkey" PRIMARY KEY ("id") CONSTRAINT "Organization_pkey" PRIMARY KEY ("id")
); );
@ -142,6 +154,9 @@ CREATE TABLE "PhoneNumber" (
CONSTRAINT "PhoneNumber_pkey" PRIMARY KEY ("id") CONSTRAINT "PhoneNumber_pkey" PRIMARY KEY ("id")
); );
-- CreateIndex
CREATE UNIQUE INDEX "TwilioAccount_organizationId_key" ON "TwilioAccount"("organizationId");
-- CreateIndex -- CreateIndex
CREATE UNIQUE INDEX "Subscription_paddleSubscriptionId_key" ON "Subscription"("paddleSubscriptionId"); 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 -- CreateIndex
CREATE UNIQUE INDEX "PhoneNumber_organizationId_isCurrent_key" ON "PhoneNumber"("organizationId", "isCurrent") WHERE ("isCurrent" = true); 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 -- AddForeignKey
ALTER TABLE "Subscription" ADD CONSTRAINT "Subscription_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; ALTER TABLE "Subscription" ADD CONSTRAINT "Subscription_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -7,13 +7,25 @@ datasource db {
url = env("DATABASE_URL") 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 { model Organization {
id String @id @default(cuid()) id String @id @default(cuid())
createdAt DateTime @default(now()) @db.Timestamptz(6) createdAt DateTime @default(now()) @db.Timestamptz(6)
updatedAt DateTime @updatedAt @db.Timestamptz(6) updatedAt DateTime @updatedAt @db.Timestamptz(6)
twilioAccountSid String?
twilioSubAccountSid String?
twilioAccount TwilioAccount?
memberships Membership[] memberships Membership[]
phoneNumbers PhoneNumber[] phoneNumbers PhoneNumber[]
subscriptions Subscription[] // many subscriptions to keep a history subscriptions Subscription[] // many subscriptions to keep a history
@ -102,7 +114,7 @@ model Message {
} }
model PhoneCall { model PhoneCall {
id String @id @unique id String @id
createdAt DateTime @default(now()) @db.Timestamptz(6) createdAt DateTime @default(now()) @db.Timestamptz(6)
from String from String
to String to String