store twilio stuff in TwilioAccount table and remodel session data
This commit is contained in:
parent
19a35bac92
commit
6684dcc0e5
@ -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,
|
||||||
|
@ -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} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
|
@ -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 },
|
||||||
|
@ -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 });
|
||||||
};
|
};
|
||||||
|
@ -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;
|
||||||
|
|
||||||
|
@ -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;
|
||||||
|
@ -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"
|
||||||
|
@ -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 }),
|
||||||
|
@ -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 }),
|
||||||
|
@ -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">
|
||||||
|
@ -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>({
|
||||||
|
@ -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,
|
||||||
|
@ -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 } },
|
||||||
});
|
});
|
||||||
|
@ -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: [] });
|
||||||
}
|
}
|
||||||
|
@ -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) => {
|
||||||
|
@ -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 };
|
|
||||||
}
|
}
|
||||||
|
@ -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");
|
||||||
|
|
||||||
|
@ -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: {
|
||||||
|
@ -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":
|
||||||
|
@ -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;
|
||||||
|
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user