list phone calls

This commit is contained in:
m5r 2021-07-22 00:48:49 +08:00
parent f55f1c5359
commit a262f61823
18 changed files with 268 additions and 105 deletions

View File

@ -1,16 +0,0 @@
export enum SmsType {
SENT = "sent",
RECEIVED = "received",
}
export type Sms = {
id: string;
customerId: string;
content: string;
from: string;
to: string;
type: SmsType;
twilioSid?: string;
// status: sent/delivered/received
sentAt: string; // timestampz
};

View File

@ -1,31 +1,44 @@
import { MessageStatus } from "twilio/lib/rest/api/v2010/account/message";
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" });
const logger = appLogger.child({ module: "message" });
export async function insertSms(messages: Omit<Sms, "id" | "twilioSid">): Promise<Sms> {
export type Message = {
id: string;
customerId: string;
content: string;
from: string;
to: string;
direction: "inbound" | "outbound";
status: MessageStatus;
twilioSid?: string;
sentAt: string; // timestampz
};
export async function insertMessage(message: Omit<Message, "id" | "twilioSid">): Promise<Message> {
const { error, data } = await supabase
.from<Sms>("sms")
.insert(messages);
.from<Message>("message")
.insert(message);
if (error) throw error;
return data![0];
}
export async function insertManySms(messages: Omit<Sms, "id">[]) {
export async function insertManyMessage(messages: Omit<Message, "id">[]) {
await supabase
.from<Sms>("sms")
.from<Message>("message")
.insert(messages)
.throwOnError();
}
export async function findCustomerMessages(customerId: Sms["customerId"]): Promise<Sms[]> {
export async function findCustomerMessages(customerId: Message["customerId"]): Promise<Message[]> {
const { error, data } = await supabase
.from<Sms>("sms")
.from<Message>("message")
.select("*")
.eq("customerId", customerId);
@ -34,9 +47,9 @@ export async function findCustomerMessages(customerId: Sms["customerId"]): Promi
return data!;
}
export async function findCustomerMessageBySid({ customerId, twilioSid }: Pick<Sms, "customerId" | "twilioSid">): Promise<Sms> {
export async function findCustomerMessageBySid({ customerId, twilioSid }: Pick<Message, "customerId" | "twilioSid">): Promise<Message> {
const { error, data } = await supabase
.from<Sms>("sms")
.from<Message>("message")
.select("*")
.eq("customerId", customerId)
.eq("twilioSid", twilioSid)
@ -47,17 +60,17 @@ export async function findCustomerMessageBySid({ customerId, twilioSid }: Pick<S
return data!;
}
export async function setTwilioSid({ id, twilioSid }: Pick<Sms, "id" | "twilioSid">) {
await supabase.from<Sms>("sms")
export async function setTwilioSid({ id, twilioSid }: Pick<Message, "id" | "twilioSid">) {
await supabase.from<Message>("message")
.update({ twilioSid })
.eq("id", id)
.throwOnError();
}
export async function findConversation(customerId: Sms["customerId"], recipient: Sms["to"]): Promise<Sms[]> {
export async function findConversation(customerId: Message["customerId"], recipient: Message["to"]): Promise<Message[]> {
const customer = await findCustomer(customerId);
const { error, data } = await supabase
.from<Sms>("sms")
.from<Message>("message")
.select("*")
.eq("customerId", customerId)
.or(`to.eq.${recipient},from.eq.${recipient}`);

View File

@ -0,0 +1,53 @@
import type { CallStatus } from "twilio/lib/rest/api/v2010/account/call";
import appLogger from "../../lib/logger";
import supabase from "../supabase/server";
const logger = appLogger.child({ module: "phone-call" });
export type PhoneCall = {
id: string;
customerId: string;
twilioSid: string;
from: string;
to: string;
status: CallStatus;
direction: "inbound" | "outbound";
duration: string;
createdAt: string; // timestampz
}
export async function insertPhoneCall(phoneCall: Omit<PhoneCall, "id" | "twilioSid">): Promise<PhoneCall> {
const { error, data } = await supabase
.from<PhoneCall>("phone-call")
.insert(phoneCall);
if (error) throw error;
return data![0];
}
export async function insertManyPhoneCalls(phoneCalls: Omit<PhoneCall, "id">[]) {
await supabase
.from<PhoneCall>("phone-call")
.insert(phoneCalls)
.throwOnError();
}
export async function findCustomerPhoneCalls(customerId: PhoneCall["customerId"]): Promise<PhoneCall[]> {
const { error, data } = await supabase
.from<PhoneCall>("phone-call")
.select("*")
.eq("customerId", customerId);
if (error) throw error;
return data!;
}
export async function setTwilioSid({ id, twilioSid }: Pick<PhoneCall, "id" | "twilioSid">) {
await supabase.from<PhoneCall>("phone-call")
.update({ twilioSid })
.eq("id", id)
.throwOnError();
}

View File

@ -1,11 +1,10 @@
import { useMutation, useQuery, useQueryClient } from "react-query";
import axios from "axios";
import type { Sms } from "../database/_types";
import { SmsType } from "../database/_types";
import type { Message } from "../database/message";
import useUser from "./use-user";
type UseConversationParams = {
initialData?: Sms[];
initialData?: Message[];
recipient: string;
}
@ -16,11 +15,11 @@ export default function useConversation({
const user = useUser();
const getConversationUrl = `/api/conversation/${encodeURIComponent(recipient)}`;
const fetcher = async () => {
const { data } = await axios.get<Sms[]>(getConversationUrl);
const { data } = await axios.get<Message[]>(getConversationUrl);
return data;
};
const queryClient = useQueryClient();
const getConversationQuery = useQuery<Sms[]>(
const getConversationQuery = useQuery<Message[]>(
getConversationUrl,
fetcher,
{
@ -31,21 +30,22 @@ export default function useConversation({
);
const sendMessage = useMutation(
(sms: Pick<Sms, "to" | "content">) => axios.post(`/api/conversation/${sms.to}/send-message`, sms, { withCredentials: true }),
(sms: Pick<Message, "to" | "content">) => axios.post(`/api/conversation/${sms.to}/send-message`, sms, { withCredentials: true }),
{
onMutate: async (sms: Pick<Sms, "to" | "content">) => {
onMutate: async (sms: Pick<Message, "to" | "content">) => {
await queryClient.cancelQueries(getConversationUrl);
const previousMessages = queryClient.getQueryData<Sms[]>(getConversationUrl);
const previousMessages = queryClient.getQueryData<Message[]>(getConversationUrl);
if (previousMessages) {
queryClient.setQueryData<Sms[]>(getConversationUrl, [
queryClient.setQueryData<Message[]>(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,
direction: "outbound",
status: "queued",
content: sms.content,
to: sms.to,
},
@ -56,7 +56,7 @@ export default function useConversation({
},
onError: (error, variables, context) => {
if (context?.previousMessages) {
queryClient.setQueryData<Sms[]>(getConversationUrl, context.previousMessages);
queryClient.setQueryData<Message[]>(getConversationUrl, context.previousMessages);
}
},
onSettled: () => queryClient.invalidateQueries(getConversationUrl),

View File

@ -1,7 +1,7 @@
import Joi from "joi";
import { withApiAuthRequired } from "../../../../../lib/session-helpers";
import { findConversation } from "../../../../database/sms";
import { findConversation } from "../../../../database/message";
import type { ApiError } from "../../_types";
import appLogger from "../../../../../lib/logger";

View File

@ -1,14 +1,12 @@
import Joi from "joi";
import { SmsType } from "../../../../database/_types";
import { withApiAuthRequired } from "../../../../../lib/session-helpers";
import { findConversation, insertSms } from "../../../../database/sms";
import { insertMessage } from "../../../../database/message";
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" });
@ -60,11 +58,12 @@ export default withApiAuthRequired(async function sendMessageHandler(
const { phoneNumber } = await findCustomerPhoneNumber(customerId);
const body: Body = validationResult.value;
const sms = await insertSms({
const sms = await insertMessage({
from: phoneNumber,
customerId: customerId,
sentAt: new Date().toISOString(),
type: SmsType.SENT,
direction: "outbound",
status: "queued",
content: encrypt(body.content, customer.encryptionKey),
to: body.to,
});

View File

@ -1,19 +1,19 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { insertSms } from "../../database/sms";
import { SmsType } from "../../database/_types";
import { insertMessage } from "../../database/message";
import { encrypt } from "../../database/_encryption";
import twilio from "twilio";
import fetchCallsQueue from "./queue/fetch-calls";
export default async function ddd(req: NextApiRequest, res: NextApiResponse) {
const accountSid = "ACa886d066be0832990d1cf43fb1d53362";
const authToken = "8696a59a64b94bb4eba3548ed815953b";
// const ddd = await twilio(accountSid, authToken).incomingPhoneNumbers.list();
const phoneNumber = "+33757592025";
const ddd = await twilio(accountSid, authToken)
/*const ddd = await twilio(accountSid, authToken)
.messages
.list({
to: phoneNumber,
});
});*/
/*const ddd = await insertSms({
to: "+213",
@ -43,6 +43,9 @@ export default async function ddd(req: NextApiRequest, res: NextApiResponse) {
voiceApplicationSid: appSid,
});*/
const customerId = "bcb723bc-9706-4811-a964-cc03018bd2ac";
const ddd = fetchCallsQueue.enqueue({ customerId }, { id: `fetch-messages-${customerId}` })
console.log("ddd", ddd);
return res.status(200).send(ddd);

View File

@ -0,0 +1,40 @@
import { Queue } from "quirrel/next";
import twilio from "twilio";
import { findCustomerPhoneNumber } from "../../../database/phone-number";
import { findCustomer } from "../../../database/customer";
import insertCallsQueue from "./insert-calls";
type Payload = {
customerId: string;
}
const fetchCallsQueue = Queue<Payload>(
"api/queue/fetch-calls",
async ({ customerId }) => {
const customer = await findCustomer(customerId);
const phoneNumber = await findCustomerPhoneNumber(customerId);
const [callsSent, callsReceived] = await Promise.all([
twilio(customer.accountSid, customer.authToken)
.calls
.list({ from: phoneNumber.phoneNumber }),
twilio(customer.accountSid, customer.authToken)
.calls
.list({ to: phoneNumber.phoneNumber })
]);
const calls = [
...callsSent,
...callsReceived,
].sort((a, b) => a.dateCreated.getTime() - b.dateCreated.getTime());
await insertCallsQueue.enqueue({
customerId,
calls,
}, {
id: `insert-calls-${customerId}`,
});
},
);
export default fetchCallsQueue;

View File

@ -15,12 +15,14 @@ const fetchMessagesQueue = Queue<Payload>(
const customer = await findCustomer(customerId);
const phoneNumber = await findCustomerPhoneNumber(customerId);
const messagesSent = await twilio(customer.accountSid, customer.authToken)
const [messagesSent, messagesReceived] = await Promise.all([
twilio(customer.accountSid, customer.authToken)
.messages
.list({ from: phoneNumber.phoneNumber });
const messagesReceived = await twilio(customer.accountSid, customer.authToken)
.list({ from: phoneNumber.phoneNumber }),
twilio(customer.accountSid, customer.authToken)
.messages
.list({ to: phoneNumber.phoneNumber });
.list({ to: phoneNumber.phoneNumber }),
]);
const messages = [
...messagesSent,
...messagesReceived,

View File

@ -0,0 +1,32 @@
import { Queue } from "quirrel/next";
import type { CallInstance } from "twilio/lib/rest/api/v2010/account/call";
import type { PhoneCall } from "../../../database/phone-call";
import { insertManyPhoneCalls } from "../../../database/phone-call";
type Payload = {
customerId: string;
calls: CallInstance[];
}
const insertCallsQueue = Queue<Payload>(
"api/queue/insert-calls",
async ({ calls, customerId }) => {
const phoneCalls = calls
.map<Omit<PhoneCall, "id">>(call => ({
customerId,
twilioSid: call.sid,
from: call.from,
to: call.to,
direction: call.direction === "inbound" ? "inbound" : "outbound",
status: call.status,
duration: call.duration,
createdAt: new Date(call.dateCreated).toISOString(),
}))
.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
await insertManyPhoneCalls(phoneCalls);
},
);
export default insertCallsQueue;

View File

@ -2,9 +2,8 @@ import { Queue } from "quirrel/next";
import type { MessageInstance } from "twilio/lib/rest/api/v2010/account/message";
import { findCustomer } from "../../../database/customer";
import type { Sms } from "../../../database/_types";
import { SmsType } from "../../../database/_types";
import { insertManySms } from "../../../database/sms";
import type { Message } from "../../../database/message";
import { insertManyMessage } from "../../../database/message";
import { encrypt } from "../../../database/_encryption";
type Payload = {
@ -18,16 +17,20 @@ const insertMessagesQueue = Queue<Payload>(
const customer = await findCustomer(customerId);
const encryptionKey = customer.encryptionKey;
const sms = messages.map<Omit<Sms, "id">>(message => ({
const sms = messages
.map<Omit<Message, "id">>(message => ({
customerId,
content: encrypt(message.body, encryptionKey),
from: message.from,
to: message.to,
type: ["received", "receiving"].includes(message.status) ? SmsType.RECEIVED : SmsType.SENT,
messageSid: message.sid,
sentAt: message.dateSent.toISOString(),
}));
await insertManySms(sms);
status: message.status,
direction: message.direction === "inbound" ? "inbound" : "outbound",
twilioSid: message.sid,
sentAt: new Date(message.dateSent).toISOString(),
}))
.sort((a, b) => a.sentAt.localeCompare(b.sentAt));
await insertManyMessage(sms);
},
);

View File

@ -3,7 +3,7 @@ import twilio from "twilio";
import { findCustomer } from "../../../database/customer";
import { findCustomerPhoneNumber } from "../../../database/phone-number";
import { setTwilioSid } from "../../../database/sms";
import { setTwilioSid } from "../../../database/message";
type Payload = {
id: string;

View File

@ -9,14 +9,14 @@ type Payload = {
}
const setTwilioWebhooks = Queue<Payload>(
"api/queue/send-message",
"api/queue/set-twilio-webhooks",
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",
smsUrl: "https://phone.mokhtar.dev/api/webhook/incoming-message",
smsMethod: "POST",
voiceUrl: "https://phone.mokhtar.dev/api/webhook/incoming-call",
voiceMethod: "POST",

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 fetchCallsQueue from "../queue/fetch-calls";
import setTwilioWebhooks from "../queue/set-twilio-webhooks";
const logger = appLogger.child({ route: "/api/user/add-phone-number" });
@ -49,6 +50,7 @@ export default withApiAuthRequired(async function addPhoneNumberHandler(req, res
await Promise.all([
fetchMessagesQueue.enqueue({ customerId }, { id: `fetch-messages-${customerId}` }),
fetchCallsQueue.enqueue({ customerId }, { id: `fetch-messages-${customerId}` }),
setTwilioWebhooks.enqueue({ customerId }, { id: `set-twilio-webhooks-${customerId}` }),
]);

View File

@ -3,14 +3,13 @@ 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 { findCustomerByPhoneNumber } from "../../../database/customer";
import { insertMessage } from "../../../database/message";
import { encrypt } from "../../../database/_encryption";
const logger = appLogger.child({ route: "/api/webhook/incoming-sms" });
const logger = appLogger.child({ route: "/api/webhook/incoming-message" });
export default async function incomingSmsHandler(req: NextApiRequest, res: NextApiResponse) {
export default async function incomingMessageHandler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== "POST") {
const statusCode = 405;
const apiError: ApiError = {
@ -41,7 +40,7 @@ export default async function incomingSmsHandler(req: NextApiRequest, res: NextA
try {
const phoneNumber = req.body.To;
const customer = await findCustomerByPhoneNumber(phoneNumber);
const url = "https://phone.mokhtar.dev/api/webhook/incoming-sms";
const url = "https://phone.mokhtar.dev/api/webhook/incoming-message";
const isRequestValid = twilio.validateRequest(customer.authToken!, twilioSignature, url, req.body);
if (!isRequestValid) {
const statusCode = 400;
@ -55,11 +54,12 @@ export default async function incomingSmsHandler(req: NextApiRequest, res: NextA
return;
}
await insertSms({
await insertMessage({
customerId: customer.id,
to: req.body.To,
from: req.body.From,
type: SmsType.RECEIVED,
status: "received",
direction: "inbound",
sentAt: req.body.DateSent,
content: encrypt(req.body.Body, customer.encryptionKey),
});

View File

@ -1,14 +1,15 @@
import type { InferGetServerSidePropsType, NextPage } from "next";
import { withPageOnboardingRequired } from "../../lib/session-helpers";
import Layout from "../components/layout";
import { findCustomerPhoneCalls } from "../database/phone-call";
import useUser from "../hooks/use-user";
import Layout from "../components/layout";
type Props = InferGetServerSidePropsType<typeof getServerSideProps>;
const pageTitle = "Calls";
const Calls: NextPage<Props> = (props) => {
const Calls: NextPage<Props> = ({ phoneCalls }) => {
const { userProfile } = useUser();
console.log("userProfile", userProfile);
@ -21,19 +22,34 @@ const Calls: NextPage<Props> = (props) => {
<Layout title={pageTitle}>
<div className="flex flex-col space-y-6 p-6">
<p>Calls page</p>
<ul className="divide-y">
{phoneCalls.map((phoneCall) => {
const recipient = phoneCall.direction === "outbound" ? phoneCall.to : phoneCall.from;
return (
<li key={phoneCall.twilioSid} className="flex flex-row justify-between py-2">
<div>{recipient}</div>
<div>{new Date(phoneCall.createdAt).toLocaleString("fr-FR")}</div>
</li>
)
})}
</ul>
</div>
</Layout>
);
};
export const getServerSideProps = withPageOnboardingRequired(
async ({ res }) => {
async ({ res }, user) => {
res.setHeader(
"Cache-Control",
"private, s-maxage=15, stale-while-revalidate=59",
);
return { props: {} };
const phoneCalls = await findCustomerPhoneCalls(user.id);
return {
props: { phoneCalls },
};
},
);

View File

@ -7,9 +7,8 @@ import clsx from "clsx";
import { useForm } from "react-hook-form";
import { withPageOnboardingRequired } from "../../../lib/session-helpers";
import { findConversation } from "../../database/sms";
import type { Sms } from "../../database/_types";
import { SmsType } from "../../database/_types";
import type { Message } from "../../database/message";
import { findConversation } from "../../database/message";
import supabase from "../../supabase/client";
import useUser from "../../hooks/use-user";
import useConversation from "../../hooks/use-conversation";
@ -17,7 +16,7 @@ import Layout from "../../components/layout";
type Props = {
recipient: string;
conversation: Sms[];
conversation: Message[];
}
type Form = {
@ -60,7 +59,7 @@ const Messages: NextPage<Props> = (props) => {
}
const subscription = supabase
.from<Sms>(`sms:customerId=eq.${userProfile.id}`)
.from<Message>(`sms:customerId=eq.${userProfile.id}`)
.on("INSERT", (payload) => {
const message = payload.new;
if ([message.from, message.to].includes(recipient)) {
@ -98,12 +97,28 @@ const Messages: NextPage<Props> = (props) => {
</header>
<div className="flex flex-col space-y-6 p-6">
<ul>
{conversation!.map(message => {
{conversation!.map((message, index) => {
const isOutbound = message.direction === "outbound";
const isSameSender = message.from === conversation![index + 1]?.from;
const isLast = index === conversation!.length;
return (
<li key={message.id} className={clsx(message.type === SmsType.SENT ? "text-right" : "text-left")}>
<li
key={message.id}
className={clsx(
isSameSender || isLast ? "pb-3" : "pb-4",
isOutbound ? "text-right" : "text-left",
)}
>
<span
className={clsx(
"p-2 rounded-lg text-white",
isOutbound ? "bg-[#3194ff] rounded-br-none" : "bg-black rounded-bl-none",
)}
>
{message.content}
</span>
</li>
)
);
})}
</ul>
</div>

View File

@ -2,9 +2,8 @@ import type { InferGetServerSidePropsType, NextPage } from "next";
import Link from "next/link";
import { withPageOnboardingRequired } from "../../../lib/session-helpers";
import type { Sms } from "../../database/_types";
import { SmsType } from "../../database/_types";
import { findCustomerMessages } from "../../database/sms";
import type { Message } from "../../database/message";
import { findCustomerMessages } from "../../database/message";
import { findCustomer } from "../../database/customer";
import { decrypt } from "../../database/_encryption";
import useUser from "../../hooks/use-user";
@ -25,15 +24,17 @@ const Messages: NextPage<Props> = ({ conversations }) => {
<Layout title={pageTitle}>
<div className="flex flex-col space-y-6 p-6">
<p>Messages page</p>
<ul>
<ul className="divide-y">
{Object.entries(conversations).map(([recipient, message]) => {
return (
<li key={recipient}>
<li key={recipient} className="py-2">
<Link href={`/messages/${recipient}`}>
<a>
<div>{recipient}</div>
<a className="flex flex-col">
<div className="flex flex-row justify-between">
<strong>{recipient}</strong>
<div>{new Date(message.sentAt).toLocaleString("fr-FR")}</div>
</div>
<div>{message.content}</div>
<div>{new Date(message.sentAt).toLocaleDateString()}</div>
</a>
</Link>
</li>
@ -59,10 +60,10 @@ export const getServerSideProps = withPageOnboardingRequired(
findCustomerMessages(user.id),
]);
let conversations: Record<Recipient, Sms> = {};
let conversations: Record<Recipient, Message> = {};
for (const message of messages) {
let recipient: string;
if (message.type === SmsType.SENT) {
if (message.direction === "outbound") {
recipient = message.to;
} else {
recipient = message.from;