migrate to blitzjs

This commit is contained in:
m5r
2021-07-31 22:33:18 +08:00
parent 4aa646ab43
commit fc4278ca7b
218 changed files with 19100 additions and 27038 deletions

View File

@ -0,0 +1,131 @@
import type { NextApiRequest, NextApiResponse } from "next"
import twilio from "twilio"
import type { ApiError } from "../../../api/_types"
import appLogger from "../../../../integrations/logger"
import { encrypt } from "../../../../db/_encryption"
import db, { Direction, MessageStatus } from "../../../../db"
import { MessageInstance } from "twilio/lib/rest/api/v2010/account/message"
const logger = appLogger.child({ route: "/api/webhook/incoming-message" })
export default async function incomingMessageHandler(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 customerPhoneNumber = await db.phoneNumber.findFirst({
where: { phoneNumber },
})
const customer = await db.customer.findFirst({
where: { id: customerPhoneNumber!.customerId },
})
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
const apiError: ApiError = {
statusCode,
errorMessage: "Invalid webhook",
}
logger.error(apiError)
res.status(statusCode).send(apiError)
return
}
await db.message.create({
data: {
customerId: customer!.id,
to: req.body.To,
from: req.body.From,
status: MessageStatus.Received,
direction: Direction.Inbound,
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)
}
}
function translateDirection(direction: MessageInstance["direction"]): Direction {
switch (direction) {
case "inbound":
return Direction.Inbound
case "outbound-api":
case "outbound-call":
case "outbound-reply":
default:
return Direction.Outbound
}
}
function translateStatus(status: MessageInstance["status"]): MessageStatus {
switch (status) {
case "accepted":
return MessageStatus.Accepted
case "canceled":
return MessageStatus.Canceled
case "delivered":
return MessageStatus.Delivered
case "failed":
return MessageStatus.Failed
case "partially_delivered":
return MessageStatus.PartiallyDelivered
case "queued":
return MessageStatus.Queued
case "read":
return MessageStatus.Read
case "received":
return MessageStatus.Received
case "receiving":
return MessageStatus.Receiving
case "scheduled":
return MessageStatus.Scheduled
case "sending":
return MessageStatus.Sending
case "sent":
return MessageStatus.Sent
case "undelivered":
return MessageStatus.Undelivered
}
}

View File

@ -0,0 +1,82 @@
import { Suspense, useEffect, useRef } from "react"
import { useRouter } from "blitz"
import clsx from "clsx"
import { Direction } from "../../../db"
import useConversation from "../hooks/use-conversation"
import NewMessageArea from "./new-message-area"
export default function Conversation() {
const router = useRouter()
const conversation = useConversation(router.params.recipient)[0]
const messagesListRef = useRef<HTMLUListElement>(null)
useEffect(() => {
messagesListRef.current?.querySelector("li:last-child")?.scrollIntoView()
}, [conversation, messagesListRef])
return (
<>
<div className="flex flex-col space-y-6 p-6 pt-12 pb-16">
<ul ref={messagesListRef}>
{conversation.map((message, index) => {
const isOutbound = message.direction === Direction.Outbound
const nextMessage = conversation![index + 1]
const previousMessage = conversation![index - 1]
const isSameNext = message.from === nextMessage?.from
const isSamePrevious = message.from === previousMessage?.from
const differenceInMinutes = previousMessage
? (new Date(message.sentAt).getTime() -
new Date(previousMessage.sentAt).getTime()) /
1000 /
60
: 0
const isTooLate = differenceInMinutes > 15
return (
<li key={message.id}>
{(!isSamePrevious || isTooLate) && (
<div className="flex py-2 space-x-1 text-xs justify-center">
<strong>
{new Date(message.sentAt).toLocaleDateString("fr-FR", {
weekday: "long",
day: "2-digit",
month: "short",
})}
</strong>
<span>
{new Date(message.sentAt).toLocaleTimeString("fr-FR", {
hour: "2-digit",
minute: "2-digit",
})}
</span>
</div>
)}
<div
className={clsx(
isSameNext ? "pb-1" : "pb-2",
isOutbound ? "text-right" : "text-left"
)}
>
<span
className={clsx(
"inline-block text-left w-[fit-content] p-2 rounded-lg text-white",
isOutbound
? "bg-[#3194ff] rounded-br-none"
: "bg-black rounded-bl-none"
)}
>
{message.content}
</span>
</div>
</li>
)
})}
</ul>
</div>
<Suspense fallback={null}>
<NewMessageArea />
</Suspense>
</>
)
}

View File

@ -0,0 +1,34 @@
import { Link, useQuery } from "blitz"
import getConversationsQuery from "../queries/get-conversations"
export default function ConversationsList() {
const conversations = useQuery(getConversationsQuery, {})[0]
if (Object.keys(conversations).length === 0) {
return <div>empty state</div>
}
return (
<ul className="divide-y">
{Object.entries(conversations).map(([recipient, messages]) => {
const lastMessage = messages[messages.length - 1]!
return (
<li key={recipient} className="py-2">
<Link href={`/messages/${encodeURIComponent(recipient)}`}>
<a className="flex flex-col">
<div className="flex flex-row justify-between">
<strong>{recipient}</strong>
<div>
{new Date(lastMessage.sentAt).toLocaleString("fr-FR")}
</div>
</div>
<div>{lastMessage.content}</div>
</a>
</Link>
</li>
)
})}
</ul>
)
}

View File

@ -0,0 +1,94 @@
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
import { faPaperPlane } from "@fortawesome/pro-regular-svg-icons"
import { useForm } from "react-hook-form"
import { useMutation, useQuery, useRouter } from "blitz"
import sendMessage from "../mutations/send-message"
import { Direction, Message, MessageStatus } from "../../../db"
import getConversationsQuery from "../queries/get-conversations"
import useCurrentCustomer from "../../core/hooks/use-current-customer"
import useCustomerPhoneNumber from "../../core/hooks/use-customer-phone-number"
type Form = {
content: string
}
export default function NewMessageArea() {
const router = useRouter()
const recipient = router.params.recipient
const { customer } = useCurrentCustomer()
const phoneNumber = useCustomerPhoneNumber()
const sendMessageMutation = useMutation(sendMessage)[0]
const { setQueryData: setConversationsQueryData, refetch: refetchConversations } = useQuery(
getConversationsQuery,
{}
)[1]
const {
register,
handleSubmit,
setValue,
formState: { isSubmitting },
} = useForm<Form>()
const onSubmit = handleSubmit(async ({ content }) => {
if (isSubmitting) {
return
}
const id = uuidv4()
const message: Message = {
id,
customerId: customer!.id,
twilioSid: id,
from: phoneNumber!.phoneNumber,
to: recipient,
content: content,
direction: Direction.Outbound,
status: MessageStatus.Queued,
sentAt: new Date(),
}
await setConversationsQueryData(
(conversations) => {
const nextConversations = { ...conversations }
if (!nextConversations[recipient]) {
nextConversations[recipient] = []
}
nextConversations[recipient] = [...nextConversations[recipient]!, message]
return nextConversations
},
{ refetch: false }
)
setValue("content", "")
await sendMessageMutation({ to: recipient, content })
await refetchConversations({ cancelRefetch: true })
})
return (
<form
onSubmit={onSubmit}
className="absolute bottom-0 w-screen backdrop-filter backdrop-blur-xl bg-white bg-opacity-75 border-t flex flex-row h-16 p-2 pr-0"
>
<textarea
className="resize-none flex-1"
autoCapitalize="sentences"
autoCorrect="on"
placeholder="Text message"
rows={1}
spellCheck
{...register("content", { required: true })}
/>
<button type="submit">
<FontAwesomeIcon size="2x" className="h-8 w-8 pl-1 pr-2" icon={faPaperPlane} />
</button>
</form>
)
}
function uuidv4() {
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
const r = (Math.random() * 16) | 0,
v = c == "x" ? r : (r & 0x3) | 0x8
return v.toString(16)
})
}

View File

@ -0,0 +1,19 @@
import { useQuery } from "blitz"
import getConversationsQuery from "../queries/get-conversations"
export default function useConversation(recipient: string) {
return useQuery(
getConversationsQuery,
{},
{
select(conversations) {
if (!conversations[recipient]) {
throw new Error("Conversation not found")
}
return conversations[recipient]!
},
}
)
}

View File

@ -0,0 +1,47 @@
import { resolver } from "blitz"
import { z } from "zod"
import db, { Direction, MessageStatus } from "../../../db"
import getCurrentCustomer from "../../customers/queries/get-current-customer"
import getCustomerPhoneNumber from "../../phone-numbers/queries/get-customer-phone-number"
import { encrypt } from "../../../db/_encryption"
import sendMessageQueue from "../../api/queue/send-message"
const Body = z.object({
content: z.string(),
to: z.string(),
})
export default resolver.pipe(
resolver.zod(Body),
resolver.authorize(),
async ({ content, to }, context) => {
const customer = await getCurrentCustomer(null, context)
const customerId = customer!.id
const customerPhoneNumber = await getCustomerPhoneNumber({ customerId }, context)
const message = await db.message.create({
data: {
customerId,
to,
from: customerPhoneNumber!.phoneNumber,
direction: Direction.Outbound,
status: MessageStatus.Queued,
content: encrypt(content, customer!.encryptionKey),
sentAt: new Date(),
},
})
await sendMessageQueue.enqueue(
{
id: message.id,
customerId,
to,
content,
},
{
id: message.id,
}
)
}
)

View File

@ -0,0 +1,25 @@
import { Suspense } from "react"
import type { BlitzPage } from "blitz"
import Layout from "../../core/layouts/layout"
import ConversationsList from "../components/conversations-list"
import useRequireOnboarding from "../../core/hooks/use-require-onboarding"
const Messages: BlitzPage = () => {
useRequireOnboarding()
return (
<Layout title="Messages">
<div className="flex flex-col space-y-6 p-6">
<p>Messages page</p>
</div>
<Suspense fallback="Loading...">
<ConversationsList />
</Suspense>
</Layout>
)
}
Messages.authenticate = true
export default Messages

View File

@ -0,0 +1,39 @@
import { Suspense } from "react"
import { BlitzPage, useRouter } from "blitz"
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
import {
faLongArrowLeft,
faInfoCircle,
faPhoneAlt as faPhone,
} from "@fortawesome/pro-regular-svg-icons"
import Layout from "../../../core/layouts/layout"
import Conversation from "../../components/conversation"
const ConversationPage: BlitzPage = () => {
const router = useRouter()
const recipient = router.params.recipient
const pageTitle = `Messages with ${recipient}`
return (
<Layout title={pageTitle} hideFooter>
<header className="absolute top-0 w-screen h-12 backdrop-filter backdrop-blur-sm bg-white bg-opacity-75 border-b grid grid-cols-3 items-center">
<span className="col-start-1 col-span-1 pl-2 cursor-pointer" onClick={router.back}>
<FontAwesomeIcon size="lg" className="h-8 w-8" icon={faLongArrowLeft} />
</span>
<strong className="col-span-1">{recipient}</strong>
<span className="col-span-1 flex justify-end space-x-4 pr-2">
<FontAwesomeIcon size="lg" className="h-8 w-8" icon={faPhone} />
<FontAwesomeIcon size="lg" className="h-8 w-8" icon={faInfoCircle} />
</span>
</header>
<Suspense fallback={<div className="pt-12">Loading messages with {recipient}</div>}>
<Conversation />
</Suspense>
</Layout>
)
}
ConversationPage.authenticate = true
export default ConversationPage

View File

@ -0,0 +1,31 @@
import { resolver } from "blitz"
import { z } from "zod"
import db, { Prisma } from "../../../db"
import { decrypt } from "../../../db/_encryption"
import getCurrentCustomer from "../../customers/queries/get-current-customer"
const GetConversations = z.object({
recipient: z.string(),
})
export default resolver.pipe(
resolver.zod(GetConversations),
resolver.authorize(),
async ({ recipient }, context) => {
const customer = await getCurrentCustomer(null, context)
const conversation = await db.message.findMany({
where: {
OR: [{ from: recipient }, { to: recipient }],
},
orderBy: { sentAt: Prisma.SortOrder.asc },
})
return conversation.map((message) => {
return {
...message,
content: decrypt(message.content, customer!.encryptionKey),
}
})
}
)

View File

@ -0,0 +1,41 @@
import { resolver } from "blitz"
import db, { Direction, Message, Prisma } from "../../../db"
import getCurrentCustomer from "../../customers/queries/get-current-customer"
import { decrypt } from "../../../db/_encryption"
export default resolver.pipe(resolver.authorize(), async (_ = null, context) => {
const customer = await getCurrentCustomer(null, context)
const messages = await db.message.findMany({
where: { customerId: customer!.id },
orderBy: { sentAt: Prisma.SortOrder.asc },
})
let conversations: Record<string, Message[]> = {}
for (const message of messages) {
let recipient: string
if (message.direction === Direction.Outbound) {
recipient = message.to
} else {
recipient = message.from
}
if (!conversations[recipient]) {
conversations[recipient] = []
}
conversations[recipient]!.push({
...message,
content: decrypt(message.content, customer!.encryptionKey),
})
conversations[recipient]!.sort((a, b) => a.sentAt.getTime() - b.sentAt.getTime())
}
conversations = Object.fromEntries(
Object.entries(conversations).sort(
([, a], [, b]) => b[b.length - 1]!.sentAt.getTime() - a[a.length - 1]!.sentAt.getTime()
)
)
return conversations
})