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,95 +18,58 @@ 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),
}); });
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<string, Conversation> = {};
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; 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<string, Conversation> = {};
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;
}

View File

@ -19,23 +19,25 @@ const action: ActionFunction = async ({ request }) => {
} }
switch (formData._action as Action) { switch (formData._action as Action) {
case "deleteUser": case "deleteUser":
return deleteUser(request); return deleteUser(request);
case "changePassword": case "changePassword":
return changePassword(request, formData); return changePassword(request, formData);
case "updateUser": case "updateUser":
return updateUser(request, formData); return updateUser(request, formData);
default: default:
const errorMessage = `POST /settings with an invalid _action=${formData._action}`; const errorMessage = `POST /settings with an invalid _action=${formData._action}`;
logger.error(errorMessage); logger.error(errorMessage);
return badRequest({ errorMessage }); return badRequest({ errorMessage });
} }
}; };
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

@ -1,185 +1,197 @@
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
} }
datasource db { datasource db {
provider = "postgresql" provider = "postgresql"
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?
memberships Membership[] twilioAccount TwilioAccount?
phoneNumbers PhoneNumber[] memberships Membership[]
subscriptions Subscription[] // many subscriptions to keep a history phoneNumbers PhoneNumber[]
subscriptions Subscription[] // many subscriptions to keep a history
} }
model Subscription { model Subscription {
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)
paddleSubscriptionId Int @id @unique paddleSubscriptionId Int @id @unique
paddlePlanId Int paddlePlanId Int
paddleCheckoutId String paddleCheckoutId String
status SubscriptionStatus status SubscriptionStatus
updateUrl String updateUrl String
cancelUrl String cancelUrl String
currency String currency String
unitPrice Float unitPrice Float
nextBillDate DateTime @db.Date nextBillDate DateTime @db.Date
lastEventTime DateTime @db.Timestamp(6) lastEventTime DateTime @db.Timestamp(6)
cancellationEffectiveDate DateTime? @db.Date cancellationEffectiveDate DateTime? @db.Date
organizationId String organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
} }
model Membership { model Membership {
id String @id @default(cuid()) id String @id @default(cuid())
role MembershipRole role MembershipRole
organizationId String organizationId String
userId String? userId String?
invitedEmail String? invitedEmail String?
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: Cascade) user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
invitationToken Token? invitationToken Token?
@@unique([organizationId, invitedEmail]) @@unique([organizationId, invitedEmail])
} }
model User { model User {
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)
fullName String fullName String
email String @unique email String @unique
hashedPassword String? hashedPassword String?
role GlobalRole @default(CUSTOMER) role GlobalRole @default(CUSTOMER)
memberships Membership[] memberships Membership[]
sessions Session[] sessions Session[]
tokens Token[] tokens Token[]
} }
model Session { model Session {
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)
expiresAt DateTime? @db.Timestamptz(6) expiresAt DateTime? @db.Timestamptz(6)
data String data String
userId String? userId String?
user User? @relation(fields: [userId], references: [id], onDelete: Cascade) user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
} }
model Token { model Token {
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)
hashedToken String hashedToken String
type TokenType type TokenType
expiresAt DateTime @db.Timestamptz(6) expiresAt DateTime @db.Timestamptz(6)
sentTo String sentTo String
userId String userId String
membershipId String @unique membershipId String @unique
membership Membership @relation(fields: [membershipId], references: [id], onDelete: Cascade) membership Membership @relation(fields: [membershipId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([hashedToken, type]) @@unique([hashedToken, type])
} }
model Message { model Message {
id String @id id String @id
sentAt DateTime @db.Timestamptz(6) sentAt DateTime @db.Timestamptz(6)
content String content String
from String from String
to String to String
direction Direction direction Direction
status MessageStatus status MessageStatus
phoneNumberId String phoneNumberId String
phoneNumber PhoneNumber @relation(fields: [phoneNumberId], references: [id], onDelete: Cascade) phoneNumber PhoneNumber @relation(fields: [phoneNumberId], references: [id], onDelete: Cascade)
} }
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
status CallStatus status CallStatus
direction Direction direction Direction
duration String duration String
phoneNumberId String phoneNumberId String
phoneNumber PhoneNumber @relation(fields: [phoneNumberId], references: [id], onDelete: Cascade) phoneNumber PhoneNumber @relation(fields: [phoneNumberId], references: [id], onDelete: Cascade)
} }
model PhoneNumber { model PhoneNumber {
id String @id id String @id
createdAt DateTime @default(now()) @db.Timestamptz(6) createdAt DateTime @default(now()) @db.Timestamptz(6)
number String number String
isCurrent Boolean isCurrent Boolean
isFetchingMessages Boolean? isFetchingMessages Boolean?
isFetchingCalls Boolean? isFetchingCalls Boolean?
organizationId String organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
messages Message[] messages Message[]
phoneCalls PhoneCall[] phoneCalls PhoneCall[]
@@unique([organizationId, isCurrent]) @@unique([organizationId, isCurrent])
} }
enum SubscriptionStatus { enum SubscriptionStatus {
active active
trialing trialing
past_due past_due
paused paused
deleted deleted
} }
enum MembershipRole { enum MembershipRole {
OWNER OWNER
USER USER
} }
enum GlobalRole { enum GlobalRole {
SUPERADMIN SUPERADMIN
CUSTOMER CUSTOMER
} }
enum TokenType { enum TokenType {
RESET_PASSWORD RESET_PASSWORD
INVITE_MEMBER INVITE_MEMBER
} }
enum Direction { enum Direction {
Inbound Inbound
Outbound Outbound
} }
enum MessageStatus { enum MessageStatus {
Queued Queued
Sending Sending
Sent Sent
Failed Failed
Delivered Delivered
Undelivered Undelivered
Receiving Receiving
Received Received
Accepted Accepted
Scheduled Scheduled
Read Read
PartiallyDelivered PartiallyDelivered
Canceled Canceled
Error Error
} }
enum CallStatus { enum CallStatus {
Queued Queued
Ringing Ringing
InProgress InProgress
Completed Completed
Busy Busy
Failed Failed
NoAnswer NoAnswer
Canceled Canceled
} }