list conversations

send sms
begin webhooks
This commit is contained in:
m5r
2021-07-21 00:28:56 +08:00
parent 3762305c4f
commit 6a12d0cd93
25 changed files with 915 additions and 86 deletions

View File

@ -4,11 +4,13 @@ export enum SmsType {
}
export type Sms = {
id: number;
id: string;
customerId: string;
content: string;
from: string;
to: string;
type: SmsType;
sentAt: Date;
twilioSid?: string;
// status: sent/delivered/received
sentAt: string; // timestampz
};

View File

@ -1,6 +1,7 @@
import appLogger from "../../lib/logger";
import supabase from "../supabase/server";
import { computeEncryptionKey } from "./_encryption";
import { findPhoneNumber } from "./phone-number";
const logger = appLogger.child({ module: "customer" });
@ -8,11 +9,12 @@ export type Customer = {
id: string;
email: string;
name: string;
paddleCustomerId: string;
paddleSubscriptionId: string;
accountSid: string;
authToken: string;
encryptionKey: string;
accountSid?: string;
authToken?: string; // TODO: should encrypt it
twimlAppSid?: string;
paddleCustomerId?: string;
paddleSubscriptionId?: string;
};
type CreateCustomerParams = Pick<Customer, "id" | "email" | "name">;
@ -47,6 +49,19 @@ export async function findCustomer(id: Customer["id"]): Promise<Customer> {
return data!;
}
export async function findCustomerByPhoneNumber(phoneNumber: string): Promise<Customer> {
const { customerId } = await findPhoneNumber(phoneNumber);
const { error, data } = await supabase
.from<Customer>("customer")
.select("*")
.eq("id", customerId)
.single();
if (error) throw error;
return data!;
}
export async function updateCustomer(id: string, update: Partial<Customer>) {
await supabase.from<Customer>("customer")
.update(update)

View File

@ -30,7 +30,7 @@ export async function createPhoneNumber({
return data![0];
}
export async function findPhoneNumber({ id }: Pick<PhoneNumber, "id">): Promise<PhoneNumber> {
export async function findPhoneNumberById({ id }: Pick<PhoneNumber, "id">): Promise<PhoneNumber> {
const { error, data } = await supabase
.from<PhoneNumber>("phone-number")
.select("*")
@ -42,6 +42,18 @@ export async function findPhoneNumber({ id }: Pick<PhoneNumber, "id">): Promise<
return data!;
}
export async function findPhoneNumber(phoneNumber: PhoneNumber["phoneNumber"]): Promise<PhoneNumber> {
const { error, data } = await supabase
.from<PhoneNumber>("phone-number")
.select("*")
.eq("phoneNumber", phoneNumber)
.single();
if (error) throw error;
return data!;
}
export async function findCustomerPhoneNumber(customerId: PhoneNumber["customerId"]): Promise<PhoneNumber> {
const { error, data } = await supabase
.from<PhoneNumber>("phone-number")

View File

@ -1,10 +1,12 @@
import appLogger from "../../lib/logger";
import supabase from "../supabase/server";
import type { Sms } from "./_types";
import { findCustomer } from "./customer";
import { decrypt } from "./_encryption";
const logger = appLogger.child({ module: "sms" });
export async function insertSms(messages: Omit<Sms, "id">): Promise<Sms> {
export async function insertSms(messages: Omit<Sms, "id" | "twilioSid">): Promise<Sms> {
const { error, data } = await supabase
.from<Sms>("sms")
.insert(messages);
@ -32,8 +34,28 @@ export async function findCustomerMessages(customerId: Sms["customerId"]): Promi
return data!;
}
export async function findCustomerMessageBySid({ customerId, twilioSid }: Pick<Sms, "customerId" | "twilioSid">): Promise<Sms> {
const { error, data } = await supabase
.from<Sms>("sms")
.select("*")
.eq("customerId", customerId)
.eq("twilioSid", twilioSid)
.single();
if (error) throw error;
return data!;
}
export async function setTwilioSid({ id, twilioSid }: Pick<Sms, "id" | "twilioSid">) {
await supabase.from<Sms>("sms")
.update({ twilioSid })
.eq("id", id)
.throwOnError();
}
export async function findConversation(customerId: Sms["customerId"], recipient: Sms["to"]): Promise<Sms[]> {
const customer = await findCustomer(customerId);
const { error, data } = await supabase
.from<Sms>("sms")
.select("*")
@ -42,5 +64,10 @@ export async function findConversation(customerId: Sms["customerId"], recipient:
if (error) throw error;
return data!;
const conversation = data!.map(message => ({
...message,
content: decrypt(message.content, customer.encryptionKey),
}));
return conversation;
}

View File

@ -0,0 +1,72 @@
import { useMutation, useQuery, useQueryClient } from "react-query";
import axios from "axios";
import type { Sms } from "../database/_types";
import { SmsType } from "../database/_types";
import useUser from "./use-user";
type UseConversationParams = {
initialData?: Sms[];
recipient: string;
}
export default function useConversation({
initialData,
recipient,
}: UseConversationParams) {
const user = useUser();
const getConversationUrl = `/api/conversation/${encodeURIComponent(recipient)}`;
const fetcher = async () => {
const { data } = await axios.get<Sms[]>(getConversationUrl);
return data;
};
const queryClient = useQueryClient();
const getConversationQuery = useQuery<Sms[]>(
getConversationUrl,
fetcher,
{
initialData,
refetchInterval: false,
refetchOnWindowFocus: false,
},
);
const sendMessage = useMutation(
(sms: Pick<Sms, "to" | "content">) => axios.post(`/api/conversation/${sms.to}/send-message`, sms, { withCredentials: true }),
{
onMutate: async (sms: Pick<Sms, "to" | "content">) => {
await queryClient.cancelQueries(getConversationUrl);
const previousMessages = queryClient.getQueryData<Sms[]>(getConversationUrl);
if (previousMessages) {
queryClient.setQueryData<Sms[]>(getConversationUrl, [
...previousMessages,
{
id: "", // TODO: somehow generate an id
from: "", // TODO: get user's phone number
customerId: user.userProfile!.id,
sentAt: new Date().toISOString(),
type: SmsType.SENT,
content: sms.content,
to: sms.to,
},
]);
}
return { previousMessages };
},
onError: (error, variables, context) => {
if (context?.previousMessages) {
queryClient.setQueryData<Sms[]>(getConversationUrl, context.previousMessages);
}
},
onSettled: () => queryClient.invalidateQueries(getConversationUrl),
},
);
return {
conversation: getConversationQuery.data,
error: getConversationQuery.error,
refetch: getConversationQuery.refetch,
sendMessage,
};
}

View File

@ -0,0 +1,54 @@
import Joi from "joi";
import { withApiAuthRequired } from "../../../../../lib/session-helpers";
import { findConversation } from "../../../../database/sms";
import type { ApiError } from "../../_types";
import appLogger from "../../../../../lib/logger";
const logger = appLogger.child({ route: "/api/conversation" });
type Query = {
recipient: string;
}
const querySchema = Joi.object<Query>({
recipient: Joi.string().required(),
});
export default withApiAuthRequired(async function getConversationHandler(
req,
res,
user,
) {
if (req.method !== "GET") {
const statusCode = 405;
const apiError: ApiError = {
statusCode,
errorMessage: `Method ${req.method} Not Allowed`,
};
logger.error(apiError);
res.setHeader("Allow", ["GET"]);
res.status(statusCode).send(apiError);
return;
}
const validationResult = querySchema.validate(req.query, { stripUnknown: true });
const validationError = validationResult.error;
if (validationError) {
const statusCode = 400;
const apiError: ApiError = {
statusCode,
errorMessage: "Query is malformed",
};
logger.error(validationError);
res.status(statusCode).send(apiError);
return;
}
const { recipient }: Query = validationResult.value;
const conversation = await findConversation(user.id, recipient);
return res.status(200).send(conversation);
});

View File

@ -0,0 +1,81 @@
import Joi from "joi";
import { SmsType } from "../../../../database/_types";
import { withApiAuthRequired } from "../../../../../lib/session-helpers";
import { findConversation, insertSms } from "../../../../database/sms";
import type { ApiError } from "../../_types";
import appLogger from "../../../../../lib/logger";
import { findCustomerPhoneNumber } from "../../../../database/phone-number";
import { encrypt } from "../../../../database/_encryption";
import { findCustomer } from "../../../../database/customer";
import twilio from "twilio";
import sendMessageQueue from "../../queue/send-message";
const logger = appLogger.child({ route: "/api/conversation" });
type Body = {
to: string;
content: string;
}
const querySchema = Joi.object<Body>({
to: Joi.string().required(),
content: Joi.string().required(),
});
export default withApiAuthRequired(async function sendMessageHandler(
req,
res,
user,
) {
if (req.method !== "POST") {
const statusCode = 405;
const apiError: ApiError = {
statusCode,
errorMessage: `Method ${req.method} Not Allowed`,
};
logger.error(apiError);
res.setHeader("Allow", ["POST"]);
res.status(statusCode).send(apiError);
return;
}
const validationResult = querySchema.validate(req.body, { stripUnknown: true });
const validationError = validationResult.error;
if (validationError) {
const statusCode = 400;
const apiError: ApiError = {
statusCode,
errorMessage: "Body is malformed",
};
logger.error(validationError);
res.status(statusCode).send(apiError);
return;
}
const customerId = user.id;
const customer = await findCustomer(customerId);
const { phoneNumber } = await findCustomerPhoneNumber(customerId);
const body: Body = validationResult.value;
const sms = await insertSms({
from: phoneNumber,
customerId: customerId,
sentAt: new Date().toISOString(),
type: SmsType.SENT,
content: encrypt(body.content, customer.encryptionKey),
to: body.to,
});
await sendMessageQueue.enqueue({
id: sms.id,
customerId,
to: body.to,
content: body.content,
}, {
id: sms.id,
});
return res.status(200).end();
});

View File

@ -1,4 +1,7 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { insertSms } from "../../database/sms";
import { SmsType } from "../../database/_types";
import { encrypt } from "../../database/_encryption";
import twilio from "twilio";
export default async function ddd(req: NextApiRequest, res: NextApiResponse) {
@ -12,6 +15,34 @@ export default async function ddd(req: NextApiRequest, res: NextApiResponse) {
to: phoneNumber,
});
/*const ddd = await insertSms({
to: "+213",
type: SmsType.SENT,
content: encrypt("slt", "4d6d431c9fd1ab7ec620655a793b527bdc4179f0df7fa05dc449d77d90669992"),
sentAt: new Date().toISOString(),
from: "+33757592025",
customerId: "bcb723bc-9706-4811-a964-cc03018bd2ac",
});*/
/*const ddd = await twilio(accountSid, authToken)
.applications
.create({
friendlyName: "Test",
smsUrl: "https://phone.mokhtar.dev/api/webhook/incoming-sms",
smsMethod: "POST",
voiceUrl: "https://phone.mokhtar.dev/api/webhook/incoming-call",
voiceMethod: "POST",
});*/
/*const appSid = "AP0f2fa971567ede86e90faaffb2fa5dc0";
const phoneNumberSid = "PNb77c9690c394368bdbaf20ea6fe5e9fc";
const ddd = await twilio(accountSid, authToken)
.incomingPhoneNumbers
.get(phoneNumberSid)
.update({
smsApplicationSid: appSid,
voiceApplicationSid: appSid,
});*/
console.log("ddd", ddd);
return res.status(200).send(ddd);

View File

@ -29,6 +29,8 @@ const fetchMessagesQueue = Queue<Payload>(
await insertMessagesQueue.enqueue({
customerId,
messages,
}, {
id: `insert-messages-${customerId}`,
});
},
);

View File

@ -24,7 +24,8 @@ const insertMessagesQueue = Queue<Payload>(
from: message.from,
to: message.to,
type: ["received", "receiving"].includes(message.status) ? SmsType.RECEIVED : SmsType.SENT,
sentAt: message.dateSent,
messageSid: message.sid,
sentAt: message.dateSent.toISOString(),
}));
await insertManySms(sms);
},

View File

@ -0,0 +1,34 @@
import { Queue } from "quirrel/next";
import twilio from "twilio";
import { findCustomer } from "../../../database/customer";
import { findCustomerPhoneNumber } from "../../../database/phone-number";
import { setTwilioSid } from "../../../database/sms";
type Payload = {
id: string;
customerId: string;
to: string;
content: string;
}
const sendMessageQueue = Queue<Payload>(
"api/queue/send-message",
async ({ id, customerId, to, content }) => {
const customer = await findCustomer(customerId);
const { phoneNumber } = await findCustomerPhoneNumber(customerId);
const message = await twilio(customer.accountSid, customer.authToken)
.messages
.create({
body: content,
to,
from: phoneNumber,
});
await setTwilioSid({ id, twilioSid: message.sid });
},
{
retry: ["1min"],
}
);
export default sendMessageQueue;

View File

@ -0,0 +1,40 @@
import { Queue } from "quirrel/next";
import twilio from "twilio";
import { findCustomer, updateCustomer } from "../../../database/customer";
import { findCustomerPhoneNumber } from "../../../database/phone-number";
type Payload = {
customerId: string;
}
const setTwilioWebhooks = Queue<Payload>(
"api/queue/send-message",
async ({ customerId }) => {
const customer = await findCustomer(customerId);
const twimlApp = await twilio(customer.accountSid, customer.authToken)
.applications
.create({
friendlyName: "Virtual Phone",
smsUrl: "https://phone.mokhtar.dev/api/webhook/incoming-sms",
smsMethod: "POST",
voiceUrl: "https://phone.mokhtar.dev/api/webhook/incoming-call",
voiceMethod: "POST",
});
const twimlAppSid = twimlApp.sid;
const { phoneNumberSid } = await findCustomerPhoneNumber(customerId);
await Promise.all([
updateCustomer(customerId, { twimlAppSid }),
twilio(customer.accountSid, customer.authToken)
.incomingPhoneNumbers
.get(phoneNumberSid)
.update({
smsApplicationSid: twimlAppSid,
voiceApplicationSid: twimlAppSid,
}),
]);
},
);
export default setTwilioWebhooks;

View File

@ -7,6 +7,7 @@ import appLogger from "../../../../lib/logger";
import { createPhoneNumber } from "../../../database/phone-number";
import { findCustomer } from "../../../database/customer";
import fetchMessagesQueue from "../queue/fetch-messages";
import setTwilioWebhooks from "../queue/set-twilio-webhooks";
const logger = appLogger.child({ route: "/api/user/add-phone-number" });
@ -14,11 +15,11 @@ type Body = {
phoneNumberSid: string;
}
export default withApiAuthRequired(async function addPhoneNumberHandler(req, res, user) {
const bodySchema = Joi.object<Body>({
phoneNumberSid: Joi.string().required(),
});
const bodySchema = Joi.object<Body>({
phoneNumberSid: Joi.string().required(),
});
export default withApiAuthRequired(async function addPhoneNumberHandler(req, res, user) {
const validationResult = bodySchema.validate(req.body, { stripUnknown: true });
const validationError = validationResult.error;
if (validationError) {
@ -46,7 +47,10 @@ export default withApiAuthRequired(async function addPhoneNumberHandler(req, res
phoneNumber: phoneNumber.phoneNumber,
});
await fetchMessagesQueue.enqueue({ customerId });
await Promise.all([
fetchMessagesQueue.enqueue({ customerId }, { id: `fetch-messages-${customerId}` }),
setTwilioWebhooks.enqueue({ customerId }, { id: `set-twilio-webhooks-${customerId}` }),
]);
return res.status(200).end();
});

View File

@ -0,0 +1,5 @@
import type { NextApiRequest, NextApiResponse } from "next";
export default async function incomingCallHandler(req: NextApiRequest, res: NextApiResponse) {
}

View File

@ -0,0 +1,76 @@
import type { NextApiRequest, NextApiResponse } from "next";
import twilio from "twilio";
import type { ApiError } from "../_types";
import appLogger from "../../../../lib/logger";
import { Customer, findCustomerByPhoneNumber } from "../../../database/customer";
import { insertSms } from "../../../database/sms";
import { SmsType } from "../../../database/_types";
import { encrypt } from "../../../database/_encryption";
const logger = appLogger.child({ route: "/api/webhook/incoming-sms" });
export default async function incomingSmsHandler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== "POST") {
const statusCode = 405;
const apiError: ApiError = {
statusCode,
errorMessage: `Method ${req.method} Not Allowed`,
};
logger.error(apiError);
res.setHeader("Allow", ["POST"]);
res.status(statusCode).send(apiError);
return;
}
const twilioSignature = req.headers["X-Twilio-Signature"] || req.headers["x-twilio-signature"];
if (!twilioSignature || Array.isArray(twilioSignature)) {
const statusCode = 400;
const apiError: ApiError = {
statusCode,
errorMessage: "Invalid header X-Twilio-Signature",
};
logger.error(apiError);
res.status(statusCode).send(apiError);
return;
}
console.log("req.body", req.body);
try {
const phoneNumber = req.body.To;
const customer = await findCustomerByPhoneNumber(phoneNumber);
const url = "https://phone.mokhtar.dev/api/webhook/incoming-sms";
const isRequestValid = twilio.validateRequest(customer.authToken!, twilioSignature, url, req.body);
if (!isRequestValid) {
const statusCode = 400;
const apiError: ApiError = {
statusCode,
errorMessage: "Invalid webhook",
};
logger.error(apiError);
res.status(statusCode).send(apiError);
return;
}
await insertSms({
customerId: customer.id,
to: req.body.To,
from: req.body.From,
type: SmsType.RECEIVED,
sentAt: req.body.DateSent,
content: encrypt(req.body.Body, customer.encryptionKey),
});
} catch (error) {
const statusCode = error.statusCode ?? 500;
const apiError: ApiError = {
statusCode,
errorMessage: error.message,
};
logger.error(error);
res.status(statusCode).send(apiError);
}
}

View File

@ -0,0 +1,5 @@
import type { NextApiRequest, NextApiResponse } from "next";
export default async function outgoingCallHandler(req: NextApiRequest, res: NextApiResponse) {
}

View File

@ -50,11 +50,10 @@ const Keypad: NextPage<Props> = () => {
<Digit digit="#" />
</Row>
<Row>
<div
className="col-start-2 h-12 w-12 flex justify-center items-center mx-auto bg-green-800 rounded-full">
<div className="select-none col-start-2 h-12 w-12 flex justify-center items-center mx-auto bg-green-800 rounded-full">
<FontAwesomeIcon icon={faPhone} color="white" size="lg" />
</div>
<div className="my-auto" onClick={pressBackspace}>
<div className="select-none my-auto" onClick={pressBackspace}>
<FontAwesomeIcon icon={faBackspace} size="lg" />
</div>
</Row>
@ -83,7 +82,7 @@ const Digit: FunctionComponent<{ digit: string }> = ({ children, digit }) => {
const onClick = () => pressDigit(digit);
return (
<div onClick={onClick} className="text-3xl cursor-pointer">
<div onClick={onClick} className="text-3xl cursor-pointer select-none">
{digit}
{children}
</div>

View File

@ -1,32 +1,92 @@
import { useEffect } from "react";
import type { NextPage } from "next";
import { useRouter } from "next/router";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faChevronLeft } from "@fortawesome/pro-regular-svg-icons";
import clsx from "clsx";
import { useForm } from "react-hook-form";
import { withPageOnboardingRequired } from "../../../lib/session-helpers";
import Layout from "../../components/layout";
import useUser from "../../hooks/use-user";
import { findConversation } from "../../database/sms";
import { decrypt } from "../../database/_encryption";
import { findCustomer } from "../../database/customer";
import type { Sms } from "../../database/_types";
import { SmsType } from "../../database/_types";
import supabase from "../../supabase/client";
import useUser from "../../hooks/use-user";
import useConversation from "../../hooks/use-conversation";
import Layout from "../../components/layout";
type Props = {
recipient: string;
conversation: Sms[];
};
}
const Messages: NextPage<Props> = ({ conversation }) => {
type Form = {
content: string;
}
const Messages: NextPage<Props> = (props) => {
const { userProfile } = useUser();
const router = useRouter();
const pageTitle = `Messages with ${router.query.recipient}`;
const recipient = router.query.recipient as string;
const { conversation, error, refetch, sendMessage } = useConversation({
initialData: props.conversation,
recipient,
});
const pageTitle = `Messages with ${recipient}`;
const {
register,
handleSubmit,
setValue,
formState: {
isSubmitting,
},
} = useForm<Form>();
console.log("userProfile", userProfile);
const onSubmit = handleSubmit(async ({ content }) => {
if (isSubmitting) {
return;
}
sendMessage.mutate({
to: recipient,
content,
});
setValue("content", "");
});
useEffect(() => {
if (!userProfile) {
return;
}
const subscription = supabase
.from<Sms>(`sms:customerId=eq.${userProfile.id}`)
.on("INSERT", (payload) => {
const message = payload.new;
if ([message.from, message.to].includes(recipient)) {
refetch();
}
})
.subscribe();
return () => void subscription.unsubscribe();
}, [userProfile, recipient, refetch]);
if (!userProfile) {
return <Layout title={pageTitle}>Loading...</Layout>;
return (
<Layout title={pageTitle}>
Loading...
</Layout>
);
}
if (error) {
console.error("error", error);
return (
<Layout title={pageTitle}>
Oops, something unexpected happened. Please try reloading the page.
</Layout>
);
}
return (
@ -38,7 +98,7 @@ const Messages: NextPage<Props> = ({ conversation }) => {
</header>
<div className="flex flex-col space-y-6 p-6">
<ul>
{conversation.map(message => {
{conversation!.map(message => {
return (
<li key={message.id} className={clsx(message.type === SmsType.SENT ? "text-right" : "text-left")}>
{message.content}
@ -47,6 +107,10 @@ const Messages: NextPage<Props> = ({ conversation }) => {
})}
</ul>
</div>
<form onSubmit={onSubmit}>
<textarea{...register("content")} />
<button type="submit">Send</button>
</form>
</Layout>
);
};
@ -63,18 +127,12 @@ export const getServerSideProps = withPageOnboardingRequired<Props>(
};
}
const customer = await findCustomer(user.id);
const conversation = await findConversation(user.id, recipient);
console.log("conversation", conversation);
console.log("recipient", recipient);
return {
props: {
recipient,
conversation: conversation.map(message => ({
...message,
content: decrypt(message.content, customer.encryptionKey),
})),
conversation,
},
};
},

View File

@ -1,14 +1,14 @@
import type { InferGetServerSidePropsType, NextPage } from "next";
import Link from "next/link";
import { withPageOnboardingRequired } from "../../lib/session-helpers";
import Layout from "../components/layout";
import useUser from "../hooks/use-user";
import type { Sms } from "../database/_types";
import { SmsType } from "../database/_types";
import { findCustomerMessages } from "../database/sms";
import { findCustomer } from "../database/customer";
import { decrypt } from "../database/_encryption";
import { withPageOnboardingRequired } from "../../../lib/session-helpers";
import type { Sms } from "../../database/_types";
import { SmsType } from "../../database/_types";
import { findCustomerMessages } from "../../database/sms";
import { findCustomer } from "../../database/customer";
import { decrypt } from "../../database/_encryption";
import useUser from "../../hooks/use-user";
import Layout from "../../components/layout";
type Props = InferGetServerSidePropsType<typeof getServerSideProps>;
@ -21,21 +21,19 @@ const Messages: NextPage<Props> = ({ conversations }) => {
return <Layout title={pageTitle}>Loading...</Layout>;
}
console.log("conversations", conversations);
return (
<Layout title={pageTitle}>
<div className="flex flex-col space-y-6 p-6">
<p>Messages page</p>
<ul>
{Object.entries(conversations).map(([recipient, conversation]) => {
const lastMessage = conversation[conversation.length - 1];
{Object.entries(conversations).map(([recipient, message]) => {
return (
<li key={recipient}>
<Link href={`/messages/${recipient}`}>
<a>
<div>{recipient}</div>
<div>{lastMessage.content}</div>
<div>{message.content}</div>
<div>{new Date(message.sentAt).toLocaleDateString()}</div>
</a>
</Link>
</li>
@ -54,7 +52,9 @@ export const getServerSideProps = withPageOnboardingRequired(
async (context, user) => {
const customer = await findCustomer(user.id);
const messages = await findCustomerMessages(user.id);
const conversations = messages.reduce<Conversation>((acc, message) => {
let conversations: Record<Recipient, Sms> = {};
for (const message of messages) {
let recipient: string;
if (message.type === SmsType.SENT) {
recipient = message.to;
@ -62,17 +62,19 @@ export const getServerSideProps = withPageOnboardingRequired(
recipient = message.from;
}
if (!acc[recipient]) {
acc[recipient] = [];
if (
!conversations[recipient] ||
message.sentAt > conversations[recipient].sentAt
) {
conversations[recipient] = {
...message,
content: decrypt(message.content, customer.encryptionKey), // TODO: should probably decrypt on the phone
};
}
acc[recipient].push({
...message,
content: decrypt(message.content, customer.encryptionKey), // TODO: should probably decrypt on the phone
});
return acc;
}, {});
}
conversations = Object.fromEntries(
Object.entries(conversations).sort(([,a], [,b]) => b.sentAt.localeCompare(a.sentAt))
);
return {
props: { conversations },

View File

@ -11,18 +11,20 @@ type Props = InferGetServerSidePropsType<typeof getServerSideProps>;
const logger = appLogger.child({ page: "/account/settings" });
/* eslint-disable react/display-name */
const navigation = [
{
name: "Account",
href: "/settings/account",
icon: ({className = "w-8 h-8"}) => <FontAwesomeIcon size="lg" className={className} icon={faUserCircle} />
icon: ({ className = "w-8 h-8" }) => <FontAwesomeIcon size="lg" className={className} icon={faUserCircle} />,
},
{
name: "Billing",
href: "/settings/billing",
icon: ({className = "w-8 h-8"}) => <FontAwesomeIcon size="lg" className={className} icon={faCreditCard} />
icon: ({ className = "w-8 h-8" }) => <FontAwesomeIcon size="lg" className={className} icon={faCreditCard} />,
},
];
/* eslint-enable react/display-name */
const Settings: NextPage<Props> = (props) => {
return (
@ -34,9 +36,9 @@ const Settings: NextPage<Props> = (props) => {
<a
key={item.name}
href={item.href}
className='border-transparent text-gray-900 hover:bg-gray-50 hover:text-gray-900 group border-l-4 px-3 py-2 flex items-center text-sm font-medium'
className="border-transparent text-gray-900 hover:bg-gray-50 hover:text-gray-900 group border-l-4 px-3 py-2 flex items-center text-sm font-medium"
>
<item.icon className='text-gray-400 group-hover:text-gray-500 flex-shrink-0 -ml-1 mr-3 h-6 w-6' />
<item.icon className="text-gray-400 group-hover:text-gray-500 flex-shrink-0 -ml-1 mr-3 h-6 w-6" />
<span className="truncate">{item.name}</span>
</a>
))}

View File

@ -46,7 +46,7 @@ const StepThree: NextPage<Props> = ({ hasTwilioCredentials, availablePhoneNumber
previous={{ href: "/welcome/step-two", label: "Back" }}
>
<div className="flex flex-col space-y-4 items-center">
<span>You don't have any phone number, fill your Twilio credentials first</span>
<span>You don&#39;t have any phone number, fill your Twilio credentials first</span>
</div>
</OnboardingLayout>
)
@ -90,7 +90,7 @@ const StepThree: NextPage<Props> = ({ hasTwilioCredentials, availablePhoneNumber
export const getServerSideProps = withPageAuthRequired(async (context, user) => {
const customer = await findCustomer(user.id);
const hasTwilioCredentials = customer.accountSid.length > 0 && customer.authToken.length > 0;
const hasTwilioCredentials = customer.accountSid?.length && customer.authToken?.length;
const incomingPhoneNumbers = await twilio(customer.accountSid, customer.authToken)
.incomingPhoneNumbers
.list();