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

4
app/api/_types.ts Normal file
View File

@ -0,0 +1,4 @@
export type ApiError = {
statusCode: number
errorMessage: string
}

16
app/api/ddd.ts Normal file
View File

@ -0,0 +1,16 @@
import { BlitzApiRequest, BlitzApiResponse } from "blitz"
import db from "db"
export default async function ddd(req: BlitzApiRequest, res: BlitzApiResponse) {
await Promise.all([
db.message.deleteMany(),
db.phoneCall.deleteMany(),
db.phoneNumber.deleteMany(),
db.customer.deleteMany(),
])
await db.user.deleteMany()
res.status(200).end()
}

View File

@ -0,0 +1,21 @@
import getConfig from "next/config"
import axios from "axios"
const { serverRuntimeConfig } = getConfig()
export async function addSubscriber(email: string) {
const { apiKey, audienceId } = serverRuntimeConfig.mailChimp
const region = apiKey.split("-")[1]
const url = `https://${region}.api.mailchimp.com/3.0/lists/${audienceId}/members`
const data = {
email_address: email,
status: "subscribed",
}
const base64ApiKey = Buffer.from(`any:${apiKey}`).toString("base64")
const headers = {
"Content-Type": "application/json",
Authorization: `Basic ${base64ApiKey}`,
}
return axios.post(url, data, { headers })
}

View File

@ -0,0 +1,59 @@
import type { NextApiRequest, NextApiResponse } from "next"
import zod from "zod"
import type { ApiError } from "../_types"
import appLogger from "../../../integrations/logger"
import { addSubscriber } from "./_mailchimp"
type Response = {} | ApiError
const logger = appLogger.child({ route: "/api/newsletter/subscribe" })
const bodySchema = zod.object({
email: zod.string().email(),
})
export default async function subscribeToNewsletter(
req: NextApiRequest,
res: NextApiResponse<Response>
) {
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
}
let body
try {
body = bodySchema.parse(req.body)
} catch (error) {
const statusCode = 400
const apiError: ApiError = {
statusCode,
errorMessage: "Body is malformed",
}
logger.error(error)
res.status(statusCode).send(apiError)
return
}
try {
await addSubscriber(body.email)
} catch (error) {
console.log("error", error.response?.data)
if (error.response?.data.title !== "Member Exists") {
return res.status(error.response?.status ?? 400).end()
}
}
res.status(200).end()
}

View File

@ -0,0 +1,38 @@
import { Queue } from "quirrel/blitz"
import twilio from "twilio"
import db from "../../../db"
import insertCallsQueue from "./insert-calls"
type Payload = {
customerId: string
}
const fetchCallsQueue = Queue<Payload>("api/queue/fetch-calls", async ({ customerId }) => {
const customer = await db.customer.findFirst({ where: { id: customerId } })
const phoneNumber = await db.phoneNumber.findFirst({ where: { 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

@ -0,0 +1,38 @@
import { Queue } from "quirrel/blitz"
import twilio from "twilio"
import db from "../../../db"
import insertMessagesQueue from "./insert-messages"
type Payload = {
customerId: string
}
const fetchMessagesQueue = Queue<Payload>("api/queue/fetch-messages", async ({ customerId }) => {
const customer = await db.customer.findFirst({ where: { id: customerId } })
const phoneNumber = await db.phoneNumber.findFirst({ where: { customerId } })
const [messagesSent, messagesReceived] = await Promise.all([
twilio(customer!.accountSid!, customer!.authToken!).messages.list({
from: phoneNumber!.phoneNumber,
}),
twilio(customer!.accountSid!, customer!.authToken!).messages.list({
to: phoneNumber!.phoneNumber,
}),
])
const messages = [...messagesSent, ...messagesReceived].sort(
(a, b) => a.dateSent.getTime() - b.dateSent.getTime()
)
await insertMessagesQueue.enqueue(
{
customerId,
messages,
},
{
id: `insert-messages-${customerId}`,
}
)
})
export default fetchMessagesQueue

View File

@ -0,0 +1,59 @@
import { Queue } from "quirrel/blitz"
import type { CallInstance } from "twilio/lib/rest/api/v2010/account/call"
import db, { Direction, CallStatus } from "../../../db"
type Payload = {
customerId: string
calls: CallInstance[]
}
const insertCallsQueue = Queue<Payload>("api/queue/insert-calls", async ({ calls, customerId }) => {
const phoneCalls = calls
.map((call) => ({
customerId,
twilioSid: call.sid,
from: call.from,
to: call.to,
direction: translateDirection(call.direction),
status: translateStatus(call.status),
duration: call.duration,
createdAt: new Date(call.dateCreated),
}))
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
await db.phoneCall.createMany({ data: phoneCalls })
})
export default insertCallsQueue
function translateDirection(direction: CallInstance["direction"]): Direction {
switch (direction) {
case "inbound":
return Direction.Inbound
case "outbound":
default:
return Direction.Outbound
}
}
function translateStatus(status: CallInstance["status"]): CallStatus {
switch (status) {
case "busy":
return CallStatus.Busy
case "canceled":
return CallStatus.Canceled
case "completed":
return CallStatus.Completed
case "failed":
return CallStatus.Failed
case "in-progress":
return CallStatus.InProgress
case "no-answer":
return CallStatus.NoAnswer
case "queued":
return CallStatus.Queued
case "ringing":
return CallStatus.Ringing
}
}

View File

@ -0,0 +1,78 @@
import { Queue } from "quirrel/blitz"
import type { MessageInstance } from "twilio/lib/rest/api/v2010/account/message"
import db, { MessageStatus, Direction, Message } from "../../../db"
import { encrypt } from "../../../db/_encryption"
type Payload = {
customerId: string
messages: MessageInstance[]
}
const insertMessagesQueue = Queue<Payload>(
"api/queue/insert-messages",
async ({ messages, customerId }) => {
const customer = await db.customer.findFirst({ where: { id: customerId } })
const encryptionKey = customer!.encryptionKey
const sms = messages
.map<Omit<Message, "id">>((message) => ({
customerId,
content: encrypt(message.body, encryptionKey),
from: message.from,
to: message.to,
status: translateStatus(message.status),
direction: translateDirection(message.direction),
twilioSid: message.sid,
sentAt: new Date(message.dateSent),
}))
.sort((a, b) => a.sentAt.getTime() - b.sentAt.getTime())
await db.message.createMany({ data: sms })
}
)
export default insertMessagesQueue
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,34 @@
import { Queue } from "quirrel/blitz"
import twilio from "twilio"
import db from "../../../db"
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 db.customer.findFirst({ where: { id: customerId } })
const phoneNumber = await db.phoneNumber.findFirst({ where: { customerId } })
const message = await twilio(customer!.accountSid!, customer!.authToken!).messages.create({
body: content,
to,
from: phoneNumber!.phoneNumber,
})
await db.message.update({
where: { id },
data: { twilioSid: message.sid },
})
},
{
retry: ["1min"],
}
)
export default sendMessageQueue

View File

@ -0,0 +1,43 @@
import { Queue } from "quirrel/blitz"
import twilio from "twilio"
import db from "../../../db"
type Payload = {
customerId: string
}
const setTwilioWebhooks = Queue<Payload>(
"api/queue/set-twilio-webhooks",
async ({ customerId }) => {
const customer = await db.customer.findFirst({ where: { id: customerId } })
const twimlApp = customer!.twimlAppSid
? await twilio(customer!.accountSid!, customer!.authToken!)
.applications.get(customer!.twimlAppSid)
.fetch()
: await twilio(customer!.accountSid!, customer!.authToken!).applications.create({
friendlyName: "Virtual Phone",
smsUrl: "https://phone.mokhtar.dev/api/webhook/incoming-message",
smsMethod: "POST",
voiceUrl: "https://phone.mokhtar.dev/api/webhook/incoming-call",
voiceMethod: "POST",
})
const twimlAppSid = twimlApp.sid
const phoneNumber = await db.phoneNumber.findFirst({ where: { customerId } })
await Promise.all([
db.customer.update({
where: { id: customerId },
data: { twimlAppSid },
}),
twilio(customer!.accountSid!, customer!.authToken!)
.incomingPhoneNumbers.get(phoneNumber!.phoneNumberSid)
.update({
smsApplicationSid: twimlAppSid,
voiceApplicationSid: twimlAppSid,
}),
])
}
)
export default setTwilioWebhooks

View File

@ -0,0 +1,61 @@
import { AuthenticationError, Link, useMutation, Routes } from "blitz"
import { LabeledTextField } from "../../core/components/labeled-text-field"
import { Form, FORM_ERROR } from "../../core/components/form"
import login from "../../../app/auth/mutations/login"
import { Login } from "../validations"
type LoginFormProps = {
onSuccess?: () => void
}
export const LoginForm = (props: LoginFormProps) => {
const [loginMutation] = useMutation(login)
return (
<div>
<h1>Login</h1>
<Form
submitText="Login"
schema={Login}
initialValues={{ email: "", password: "" }}
onSubmit={async (values) => {
try {
await loginMutation(values)
props.onSuccess?.()
} catch (error) {
if (error instanceof AuthenticationError) {
return { [FORM_ERROR]: "Sorry, those credentials are invalid" }
} else {
return {
[FORM_ERROR]:
"Sorry, we had an unexpected error. Please try again. - " +
error.toString(),
}
}
}
}}
>
<LabeledTextField name="email" label="Email" placeholder="Email" />
<LabeledTextField
name="password"
label="Password"
placeholder="Password"
type="password"
/>
<div>
<Link href={Routes.ForgotPasswordPage()}>
<a>Forgot your password?</a>
</Link>
</div>
</Form>
<div style={{ marginTop: "1rem" }}>
Or <Link href={Routes.SignupPage()}>Sign Up</Link>
</div>
</div>
)
}
export default LoginForm

View File

@ -0,0 +1,49 @@
import { useMutation } from "blitz"
import { LabeledTextField } from "../../core/components/labeled-text-field"
import { Form, FORM_ERROR } from "../../core/components/form"
import signup from "../../auth/mutations/signup"
import { Signup } from "../validations"
type SignupFormProps = {
onSuccess?: () => void
}
export const SignupForm = (props: SignupFormProps) => {
const [signupMutation] = useMutation(signup)
return (
<div>
<h1>Create an Account</h1>
<Form
submitText="Create Account"
schema={Signup}
initialValues={{ email: "", password: "" }}
onSubmit={async (values) => {
try {
await signupMutation(values)
props.onSuccess?.()
} catch (error) {
if (error.code === "P2002" && error.meta?.target?.includes("email")) {
// This error comes from Prisma
return { email: "This email is already being used" }
} else {
return { [FORM_ERROR]: error.toString() }
}
}
}}
>
<LabeledTextField name="email" label="Email" placeholder="Email" />
<LabeledTextField
name="password"
label="Password"
placeholder="Password"
type="password"
/>
</Form>
</div>
)
}
export default SignupForm

View File

@ -0,0 +1,24 @@
import { NotFoundError, SecurePassword, resolver } from "blitz"
import db from "../../../db"
import { authenticateUser } from "./login"
import { ChangePassword } from "../validations"
export default resolver.pipe(
resolver.zod(ChangePassword),
resolver.authorize(),
async ({ currentPassword, newPassword }, ctx) => {
const user = await db.user.findFirst({ where: { id: ctx.session.userId! } })
if (!user) throw new NotFoundError()
await authenticateUser(user.email, currentPassword)
const hashedPassword = await SecurePassword.hash(newPassword.trim())
await db.user.update({
where: { id: user.id },
data: { hashedPassword },
})
return true
}
)

View File

@ -0,0 +1,61 @@
import { hash256, Ctx } from "blitz"
import previewEmail from "preview-email"
import forgotPassword from "./forgot-password"
import db from "../../../db"
beforeEach(async () => {
await db.$reset()
})
const generatedToken = "plain-token"
jest.mock("blitz", () => ({
...jest.requireActual<object>("blitz")!,
generateToken: () => generatedToken,
}))
jest.mock("preview-email", () => jest.fn())
describe("forgotPassword mutation", () => {
it("does not throw error if user doesn't exist", async () => {
await expect(
forgotPassword({ email: "no-user@email.com" }, {} as Ctx)
).resolves.not.toThrow()
})
it("works correctly", async () => {
// Create test user
const user = await db.user.create({
data: {
email: "user@example.com",
tokens: {
// Create old token to ensure it's deleted
create: {
type: "RESET_PASSWORD",
hashedToken: "token",
expiresAt: new Date(),
sentTo: "user@example.com",
},
},
},
include: { tokens: true },
})
// Invoke the mutation
await forgotPassword({ email: user.email }, {} as Ctx)
const tokens = await db.token.findMany({ where: { userId: user.id } })
const token = tokens[0]
if (!user.tokens[0]) throw new Error("Missing user token")
if (!token) throw new Error("Missing token")
// delete's existing tokens
expect(tokens.length).toBe(1)
expect(token.id).not.toBe(user.tokens[0].id)
expect(token.type).toBe("RESET_PASSWORD")
expect(token.sentTo).toBe(user.email)
expect(token.hashedToken).toBe(hash256(generatedToken))
expect(token.expiresAt > new Date()).toBe(true)
expect(previewEmail).toBeCalled()
})
})

View File

@ -0,0 +1,42 @@
import { resolver, generateToken, hash256 } from "blitz"
import db from "../../../db"
import { forgotPasswordMailer } from "../../../mailers/forgot-password-mailer"
import { ForgotPassword } from "../validations"
const RESET_PASSWORD_TOKEN_EXPIRATION_IN_HOURS = 4
export default resolver.pipe(resolver.zod(ForgotPassword), async ({ email }) => {
// 1. Get the user
const user = await db.user.findFirst({ where: { email: email.toLowerCase() } })
// 2. Generate the token and expiration date.
const token = generateToken()
const hashedToken = hash256(token)
const expiresAt = new Date()
expiresAt.setHours(expiresAt.getHours() + RESET_PASSWORD_TOKEN_EXPIRATION_IN_HOURS)
// 3. If user with this email was found
if (user) {
// 4. Delete any existing password reset tokens
await db.token.deleteMany({ where: { type: "RESET_PASSWORD", userId: user.id } })
// 5. Save this new token in the database.
await db.token.create({
data: {
user: { connect: { id: user.id } },
type: "RESET_PASSWORD",
expiresAt,
hashedToken,
sentTo: user.email,
},
})
// 6. Send the email
await forgotPasswordMailer({ to: user.email, token }).send()
} else {
// 7. If no user found wait the same time so attackers can't tell the difference
await new Promise((resolve) => setTimeout(resolve, 750))
}
// 8. Return the same result whether a password reset email was sent or not
return
})

View File

@ -0,0 +1,31 @@
import { resolver, SecurePassword, AuthenticationError } from "blitz"
import db, { Role } from "../../../db"
import { Login } from "../validations"
export const authenticateUser = async (rawEmail: string, rawPassword: string) => {
const email = rawEmail.toLowerCase().trim()
const password = rawPassword.trim()
const user = await db.user.findFirst({ where: { email } })
if (!user) throw new AuthenticationError()
const result = await SecurePassword.verify(user.hashedPassword, password)
if (result === SecurePassword.VALID_NEEDS_REHASH) {
// Upgrade hashed password with a more secure hash
const improvedHash = await SecurePassword.hash(password)
await db.user.update({ where: { id: user.id }, data: { hashedPassword: improvedHash } })
}
const { hashedPassword, ...rest } = user
return rest
}
export default resolver.pipe(resolver.zod(Login), async ({ email, password }, ctx) => {
// This throws an error if credentials are invalid
const user = await authenticateUser(email, password)
await ctx.session.$create({ userId: user.id, role: user.role as Role })
return user
})

View File

@ -0,0 +1,5 @@
import { Ctx } from "blitz"
export default async function logout(_: any, ctx: Ctx) {
return await ctx.session.$revoke()
}

View File

@ -0,0 +1,83 @@
import { hash256, SecurePassword } from "blitz"
import db from "../../../db"
import resetPassword from "./reset-password"
beforeEach(async () => {
await db.$reset()
})
const mockCtx: any = {
session: {
$create: jest.fn,
},
}
describe("resetPassword mutation", () => {
it("works correctly", async () => {
expect(true).toBe(true)
// Create test user
const goodToken = "randomPasswordResetToken"
const expiredToken = "expiredRandomPasswordResetToken"
const future = new Date()
future.setHours(future.getHours() + 4)
const past = new Date()
past.setHours(past.getHours() - 4)
const user = await db.user.create({
data: {
email: "user@example.com",
tokens: {
// Create old token to ensure it's deleted
create: [
{
type: "RESET_PASSWORD",
hashedToken: hash256(expiredToken),
expiresAt: past,
sentTo: "user@example.com",
},
{
type: "RESET_PASSWORD",
hashedToken: hash256(goodToken),
expiresAt: future,
sentTo: "user@example.com",
},
],
},
},
include: { tokens: true },
})
const newPassword = "newPassword"
// Non-existent token
await expect(
resetPassword({ token: "no-token", password: "", passwordConfirmation: "" }, mockCtx)
).rejects.toThrowError()
// Expired token
await expect(
resetPassword(
{ token: expiredToken, password: newPassword, passwordConfirmation: newPassword },
mockCtx
)
).rejects.toThrowError()
// Good token
await resetPassword(
{ token: goodToken, password: newPassword, passwordConfirmation: newPassword },
mockCtx
)
// Delete's the token
const numberOfTokens = await db.token.count({ where: { userId: user.id } })
expect(numberOfTokens).toBe(0)
// Updates user's password
const updatedUser = await db.user.findFirst({ where: { id: user.id } })
expect(await SecurePassword.verify(updatedUser!.hashedPassword, newPassword)).toBe(
SecurePassword.VALID
)
})
})

View File

@ -0,0 +1,48 @@
import { resolver, SecurePassword, hash256 } from "blitz"
import db from "../../../db"
import { ResetPassword } from "../validations"
import login from "./login"
export class ResetPasswordError extends Error {
name = "ResetPasswordError"
message = "Reset password link is invalid or it has expired."
}
export default resolver.pipe(resolver.zod(ResetPassword), async ({ password, token }, ctx) => {
// 1. Try to find this token in the database
const hashedToken = hash256(token)
const possibleToken = await db.token.findFirst({
where: { hashedToken, type: "RESET_PASSWORD" },
include: { user: true },
})
// 2. If token not found, error
if (!possibleToken) {
throw new ResetPasswordError()
}
const savedToken = possibleToken
// 3. Delete token so it can't be used again
await db.token.delete({ where: { id: savedToken.id } })
// 4. If token has expired, error
if (savedToken.expiresAt < new Date()) {
throw new ResetPasswordError()
}
// 5. Since token is valid, now we can update the user's password
const hashedPassword = await SecurePassword.hash(password.trim())
const user = await db.user.update({
where: { id: savedToken.userId },
data: { hashedPassword },
})
// 6. Revoke all existing login sessions for this user
await db.session.deleteMany({ where: { userId: user.id } })
// 7. Now log the user in with the new credentials
await login({ email: user.email, password }, ctx)
return true
})

View File

@ -0,0 +1,18 @@
import { resolver, SecurePassword } from "blitz"
import db, { Role } from "../../../db"
import { Signup } from "../validations"
import { computeEncryptionKey } from "../../../db/_encryption"
export default resolver.pipe(resolver.zod(Signup), async ({ email, password }, ctx) => {
const hashedPassword = await SecurePassword.hash(password.trim())
const user = await db.user.create({
data: { email: email.toLowerCase().trim(), hashedPassword, role: Role.USER },
select: { id: true, name: true, email: true, role: true },
})
const encryptionKey = computeEncryptionKey(user.id).toString("hex")
await db.customer.create({ data: { id: user.id, encryptionKey } })
await ctx.session.$create({ userId: user.id, role: user.role })
return user
})

View File

@ -0,0 +1,52 @@
import { BlitzPage, useMutation } from "blitz"
import BaseLayout from "../../core/layouts/base-layout"
import { LabeledTextField } from "../../core/components/labeled-text-field"
import { Form, FORM_ERROR } from "../../core/components/form"
import { ForgotPassword } from "../validations"
import forgotPassword from "../../auth/mutations/forgot-password"
const ForgotPasswordPage: BlitzPage = () => {
const [forgotPasswordMutation, { isSuccess }] = useMutation(forgotPassword)
return (
<div>
<h1>Forgot your password?</h1>
{isSuccess ? (
<div>
<h2>Request Submitted</h2>
<p>
If your email is in our system, you will receive instructions to reset your
password shortly.
</p>
</div>
) : (
<Form
submitText="Send Reset Password Instructions"
schema={ForgotPassword}
initialValues={{ email: "" }}
onSubmit={async (values) => {
try {
await forgotPasswordMutation(values)
} catch (error) {
return {
[FORM_ERROR]:
"Sorry, we had an unexpected error. Please try again.",
}
}
}}
>
<LabeledTextField name="email" label="Email" placeholder="Email" />
</Form>
)}
</div>
)
}
ForgotPasswordPage.redirectAuthenticatedTo = "/"
ForgotPasswordPage.getLayout = (page) => (
<BaseLayout title="Forgot Your Password?">{page}</BaseLayout>
)
export default ForgotPasswordPage

26
app/auth/pages/login.tsx Normal file
View File

@ -0,0 +1,26 @@
import { useRouter, BlitzPage } from "blitz"
import BaseLayout from "../../core/layouts/base-layout"
import { LoginForm } from "../components/login-form"
const LoginPage: BlitzPage = () => {
const router = useRouter()
return (
<div>
<LoginForm
onSuccess={() => {
const next = router.query.next
? decodeURIComponent(router.query.next as string)
: "/"
router.push(next)
}}
/>
</div>
)
}
LoginPage.redirectAuthenticatedTo = "/"
LoginPage.getLayout = (page) => <BaseLayout title="Log In">{page}</BaseLayout>
export default LoginPage

View File

@ -0,0 +1,65 @@
import { BlitzPage, useRouterQuery, Link, useMutation, Routes } from "blitz"
import BaseLayout from "../../core/layouts/base-layout"
import { LabeledTextField } from "../../core/components/labeled-text-field"
import { Form, FORM_ERROR } from "../../core/components/form"
import { ResetPassword } from "../validations"
import resetPassword from "../../auth/mutations/reset-password"
const ResetPasswordPage: BlitzPage = () => {
const query = useRouterQuery()
const [resetPasswordMutation, { isSuccess }] = useMutation(resetPassword)
return (
<div>
<h1>Set a New Password</h1>
{isSuccess ? (
<div>
<h2>Password Reset Successfully</h2>
<p>
Go to the <Link href={Routes.Home()}>homepage</Link>
</p>
</div>
) : (
<Form
submitText="Reset Password"
schema={ResetPassword}
initialValues={{
password: "",
passwordConfirmation: "",
token: query.token as string,
}}
onSubmit={async (values) => {
try {
await resetPasswordMutation(values)
} catch (error) {
if (error.name === "ResetPasswordError") {
return {
[FORM_ERROR]: error.message,
}
} else {
return {
[FORM_ERROR]:
"Sorry, we had an unexpected error. Please try again.",
}
}
}
}}
>
<LabeledTextField name="password" label="New Password" type="password" />
<LabeledTextField
name="passwordConfirmation"
label="Confirm New Password"
type="password"
/>
</Form>
)}
</div>
)
}
ResetPasswordPage.redirectAuthenticatedTo = "/"
ResetPasswordPage.getLayout = (page) => <BaseLayout title="Reset Your Password">{page}</BaseLayout>
export default ResetPasswordPage

19
app/auth/pages/signup.tsx Normal file
View File

@ -0,0 +1,19 @@
import { useRouter, BlitzPage, Routes } from "blitz"
import BaseLayout from "../../core/layouts/base-layout"
import { SignupForm } from "../components/signup-form"
const SignupPage: BlitzPage = () => {
const router = useRouter()
return (
<div>
<SignupForm onSuccess={() => router.push(Routes.Home())} />
</div>
)
}
SignupPage.redirectAuthenticatedTo = "/"
SignupPage.getLayout = (page) => <BaseLayout title="Sign Up">{page}</BaseLayout>
export default SignupPage

33
app/auth/validations.ts Normal file
View File

@ -0,0 +1,33 @@
import { z } from "zod"
const password = z.string().min(10).max(100)
export const Signup = z.object({
email: z.string().email(),
password,
})
export const Login = z.object({
email: z.string().email(),
password: z.string(),
})
export const ForgotPassword = z.object({
email: z.string().email(),
})
export const ResetPassword = z
.object({
password: password,
passwordConfirmation: password,
token: z.string(),
})
.refine((data) => data.password === data.passwordConfirmation, {
message: "Passwords don't match",
path: ["passwordConfirmation"], // set the path of the error
})
export const ChangePassword = z.object({
currentPassword: z.string(),
newPassword: password,
})

View File

@ -0,0 +1,84 @@
import { useState, ReactNode, PropsWithoutRef } from "react"
import { FormProvider, useForm, UseFormProps } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
export interface FormProps<S extends z.ZodType<any, any>>
extends Omit<PropsWithoutRef<JSX.IntrinsicElements["form"]>, "onSubmit"> {
/** All your form fields */
children?: ReactNode
/** Text to display in the submit button */
submitText?: string
schema?: S
onSubmit: (values: z.infer<S>) => Promise<void | OnSubmitResult>
initialValues?: UseFormProps<z.infer<S>>["defaultValues"]
}
interface OnSubmitResult {
FORM_ERROR?: string
[prop: string]: any
}
export const FORM_ERROR = "FORM_ERROR"
export function Form<S extends z.ZodType<any, any>>({
children,
submitText,
schema,
initialValues,
onSubmit,
...props
}: FormProps<S>) {
const ctx = useForm<z.infer<S>>({
mode: "onBlur",
resolver: schema ? zodResolver(schema) : undefined,
defaultValues: initialValues,
})
const [formError, setFormError] = useState<string | null>(null)
return (
<FormProvider {...ctx}>
<form
onSubmit={ctx.handleSubmit(async (values) => {
const result = (await onSubmit(values)) || {}
for (const [key, value] of Object.entries(result)) {
if (key === FORM_ERROR) {
setFormError(value)
} else {
ctx.setError(key as any, {
type: "submit",
message: value,
})
}
}
})}
className="form"
{...props}
>
{/* Form fields supplied as children are rendered here */}
{children}
{formError && (
<div role="alert" style={{ color: "red" }}>
{formError}
</div>
)}
{submitText && (
<button type="submit" disabled={ctx.formState.isSubmitting}>
{submitText}
</button>
)}
<style global jsx>{`
.form > * + * {
margin-top: 1rem;
}
`}</style>
</form>
</FormProvider>
)
}
export default Form

View File

@ -0,0 +1,58 @@
import { forwardRef, PropsWithoutRef } from "react"
import { useFormContext } from "react-hook-form"
export interface LabeledTextFieldProps extends PropsWithoutRef<JSX.IntrinsicElements["input"]> {
/** Field name. */
name: string
/** Field label. */
label: string
/** Field type. Doesn't include radio buttons and checkboxes */
type?: "text" | "password" | "email" | "number"
outerProps?: PropsWithoutRef<JSX.IntrinsicElements["div"]>
}
export const LabeledTextField = forwardRef<HTMLInputElement, LabeledTextFieldProps>(
({ label, outerProps, name, ...props }, ref) => {
const {
register,
formState: { isSubmitting, errors },
} = useFormContext()
const error = Array.isArray(errors[name])
? errors[name].join(", ")
: errors[name]?.message || errors[name]
return (
<div {...outerProps}>
<label>
{label}
<input disabled={isSubmitting} {...register(name)} {...props} />
</label>
{error && (
<div role="alert" style={{ color: "red" }}>
{error}
</div>
)}
<style jsx>{`
label {
display: flex;
flex-direction: column;
align-items: start;
font-size: 1rem;
}
input {
font-size: 1rem;
padding: 0.25rem 0.5rem;
border-radius: 3px;
border: 1px solid purple;
appearance: none;
margin-top: 0.5rem;
}
`}</style>
</div>
)
}
)
export default LabeledTextField

View File

@ -0,0 +1,11 @@
import { useQuery } from "blitz"
import getCurrentCustomer from "../../customers/queries/get-current-customer"
export default function useCurrentCustomer() {
const [customer] = useQuery(getCurrentCustomer, null)
return {
customer,
hasCompletedOnboarding: Boolean(!!customer && customer.accountSid && customer.authToken),
}
}

View File

@ -0,0 +1,15 @@
import { useQuery } from "blitz"
import getCurrentCustomerPhoneNumber from "../../phone-numbers/queries/get-current-customer-phone-number"
import useCurrentCustomer from "./use-current-customer"
export default function useCustomerPhoneNumber() {
const { hasCompletedOnboarding } = useCurrentCustomer()
const [customerPhoneNumber] = useQuery(
getCurrentCustomerPhoneNumber,
{},
{ enabled: hasCompletedOnboarding }
)
return customerPhoneNumber
}

View File

@ -0,0 +1,24 @@
import { Routes, useRouter } from "blitz"
import useCurrentCustomer from "./use-current-customer"
import useCustomerPhoneNumber from "./use-customer-phone-number"
export default function useRequireOnboarding() {
const router = useRouter()
const { customer, hasCompletedOnboarding } = useCurrentCustomer()
const customerPhoneNumber = useCustomerPhoneNumber()
if (!hasCompletedOnboarding) {
throw router.push(Routes.StepTwo())
}
/*if (!customer.paddleCustomerId || !customer.paddleSubscriptionId) {
throw router.push(Routes.StepTwo());
return;
}*/
console.log("customerPhoneNumber", customerPhoneNumber)
if (!customerPhoneNumber) {
throw router.push(Routes.StepThree())
}
}

View File

@ -0,0 +1,22 @@
import { ReactNode } from "react"
import { Head } from "blitz"
type LayoutProps = {
title?: string
children: ReactNode
}
const BaseLayout = ({ title, children }: LayoutProps) => {
return (
<>
<Head>
<title>{title || "virtual-phone"}</title>
<link rel="icon" href="/favicon.ico" />
</Head>
{children}
</>
)
}
export default BaseLayout

View File

@ -0,0 +1,81 @@
import type { ReactNode } from "react"
import Link from "next/link"
import { useRouter } from "next/router"
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
import {
faPhoneAlt as fasPhone,
faTh as fasTh,
faComments as fasComments,
faCog as fasCog,
} from "@fortawesome/pro-solid-svg-icons"
import {
faPhoneAlt as farPhone,
faTh as farTh,
faComments as farComments,
faCog as farCog,
} from "@fortawesome/pro-regular-svg-icons"
export default function Footer() {
return (
<footer className="grid grid-cols-4" style={{ flex: "0 0 50px" }}>
<NavLink
label="Calls"
path="/calls"
icons={{
active: <FontAwesomeIcon size="lg" className="w-6 h-6" icon={fasPhone} />,
inactive: <FontAwesomeIcon size="lg" className="w-6 h-6" icon={farPhone} />,
}}
/>
<NavLink
label="Keypad"
path="/keypad"
icons={{
active: <FontAwesomeIcon size="lg" className="w-6 h-6" icon={fasTh} />,
inactive: <FontAwesomeIcon size="lg" className="w-6 h-6" icon={farTh} />,
}}
/>
<NavLink
label="Messages"
path="/messages"
icons={{
active: <FontAwesomeIcon size="lg" className="w-6 h-6" icon={fasComments} />,
inactive: <FontAwesomeIcon size="lg" className="w-6 h-6" icon={farComments} />,
}}
/>
<NavLink
label="Settings"
path="/settings"
icons={{
active: <FontAwesomeIcon size="lg" className="w-6 h-6" icon={fasCog} />,
inactive: <FontAwesomeIcon size="lg" className="w-6 h-6" icon={farCog} />,
}}
/>
</footer>
)
}
type NavLinkProps = {
path: string
label: string
icons: {
active: ReactNode
inactive: ReactNode
}
}
function NavLink({ path, label, icons }: NavLinkProps) {
const router = useRouter()
const isActiveRoute = router.pathname.startsWith(path)
const icon = isActiveRoute ? icons.active : icons.inactive
return (
<div className="flex flex-col items-center justify-around h-full">
<Link href={path}>
<a className="flex flex-col items-center">
{icon}
<span className="text-xs">{label}</span>
</a>
</Link>
</div>
)
}

View File

@ -0,0 +1,101 @@
import type { ErrorInfo, FunctionComponent } from "react"
import { Component } from "react"
import Head from "next/head"
import type { WithRouterProps } from "next/dist/client/with-router"
import { withRouter } from "next/router"
import appLogger from "../../../../integrations/logger"
import Footer from "./footer"
type Props = {
title: string
pageTitle?: string
hideFooter?: true
}
const logger = appLogger.child({ module: "Layout" })
const Layout: FunctionComponent<Props> = ({
children,
title,
pageTitle = title,
hideFooter = false,
}) => {
return (
<>
{pageTitle ? (
<Head>
<title>{pageTitle}</title>
</Head>
) : null}
<div className="h-full w-full overflow-hidden fixed bg-gray-50">
<div className="flex flex-col w-full h-full">
<div className="flex flex-col flex-1 w-full overflow-y-auto">
<main className="flex-1 my-0 h-full">
<ErrorBoundary>{children}</ErrorBoundary>
</main>
</div>
{!hideFooter ? <Footer /> : null}
</div>
</div>
</>
)
}
type ErrorBoundaryState =
| {
isError: false
}
| {
isError: true
errorMessage: string
}
const ErrorBoundary = withRouter(
class ErrorBoundary extends Component<WithRouterProps, ErrorBoundaryState> {
public readonly state = {
isError: false,
} as const
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return {
isError: true,
errorMessage: error.message,
}
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
logger.error(error, errorInfo.componentStack)
}
public render() {
if (this.state.isError) {
return (
<>
<h2 className="mt-6 text-center text-3xl leading-9 font-extrabold text-gray-900">
Oops, something went wrong.
</h2>
<p className="mt-2 text-center text-lg leading-5 text-gray-600">
Would you like to{" "}
<button
className="inline-flex space-x-2 items-center text-left"
onClick={this.props.router.reload}
>
<span className="transition-colors duration-150 border-b border-primary-200 hover:border-primary-500">
reload the page
</span>
</button>{" "}
?
</p>
</>
)
}
return this.props.children
}
}
)
export default Layout

View File

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@ -0,0 +1,21 @@
import { Ctx } from "blitz"
import db from "../../../db"
export default async function getCurrentCustomer(_ = null, { session }: Ctx) {
if (!session.userId) return null
return db.customer.findFirst({
where: { id: session.userId },
select: {
id: true,
encryptionKey: true,
accountSid: true,
authToken: true,
twimlAppSid: true,
paddleCustomerId: true,
paddleSubscriptionId: true,
user: true,
},
})
}

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
})

View File

@ -0,0 +1,113 @@
import type { FunctionComponent } from "react"
import { CheckIcon } from "@heroicons/react/solid"
import clsx from "clsx"
import { Link, Routes, useRouter } from "blitz"
import useCustomerPhoneNumber from "../../core/hooks/use-customer-phone-number"
type StepLink = {
href: string
label: string
}
type Props = {
currentStep: 1 | 2 | 3
previous?: StepLink
next?: StepLink
}
const steps = ["Welcome", "Twilio Credentials", "Pick a plan"] as const
const OnboardingLayout: FunctionComponent<Props> = ({ children, currentStep, previous, next }) => {
const router = useRouter()
const customerPhoneNumber = useCustomerPhoneNumber()
if (customerPhoneNumber) {
throw router.push(Routes.Messages())
}
return (
<div className="bg-gray-800 fixed z-10 inset-0 overflow-y-auto">
<div className="min-h-screen text-center block p-0">
{/* This element is to trick the browser into centering the modal contents. */}
<span className="inline-block align-middle h-screen">&#8203;</span>
<div className="inline-flex flex-col bg-white rounded-lg text-left overflow-hidden shadow transform transition-all my-8 align-middle max-w-2xl w-[90%] pb-6">
<h3 className="text-lg leading-6 font-medium text-gray-900 px-6 py-5 border-b border-gray-100">
{steps[currentStep - 1]}
</h3>
<section className="px-6 pt-6 pb-12">{children}</section>
<nav className="grid grid-cols-1 gap-y-3 mx-auto">
{next ? (
<Link href={next.href}>
<a className="max-w-[240px] mx-auto w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-primary-600 text-base font-medium text-white hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:text-sm">
{next.label}
</a>
</Link>
) : null}
{previous ? (
<Link href={previous.href}>
<a className="max-w-[240px] mx-auto w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:text-sm">
{previous.label}
</a>
</Link>
) : null}
<ol className="flex items-center">
{steps.map((step, stepIdx) => {
const isComplete = currentStep > stepIdx + 1
const isCurrent = stepIdx + 1 === currentStep
return (
<li
key={step}
className={clsx(
stepIdx !== steps.length - 1 ? "pr-20 sm:pr-32" : "",
"relative"
)}
>
{isComplete ? (
<>
<div className="absolute inset-0 flex items-center">
<div className="h-0.5 w-full bg-primary-600" />
</div>
<a className="relative w-8 h-8 flex items-center justify-center bg-primary-600 rounded-full">
<CheckIcon className="w-5 h-5 text-white" />
<span className="sr-only">{step}</span>
</a>
</>
) : isCurrent ? (
<>
<div className="absolute inset-0 flex items-center">
<div className="h-0.5 w-full bg-gray-200" />
</div>
<a className="relative w-8 h-8 flex items-center justify-center bg-white border-2 border-primary-600 rounded-full">
<span className="h-2.5 w-2.5 bg-primary-600 rounded-full" />
<span className="sr-only">{step}</span>
</a>
</>
) : (
<>
<div className="absolute inset-0 flex items-center">
<div className="h-0.5 w-full bg-gray-200" />
</div>
<a className="group relative w-8 h-8 flex items-center justify-center bg-white border-2 border-gray-300 rounded-full">
<span className="h-2.5 w-2.5 bg-transparent rounded-full" />
<span className="sr-only">{step}</span>
</a>
</>
)}
</li>
)
})}
</ol>
</nav>
</div>
</div>
</div>
)
}
export default OnboardingLayout

View File

@ -0,0 +1,40 @@
import { resolver } from "blitz"
import { z } from "zod"
import twilio from "twilio"
import db from "../../../db"
import getCurrentCustomer from "../../customers/queries/get-current-customer"
import fetchMessagesQueue from "../../api/queue/fetch-messages"
import fetchCallsQueue from "../../api/queue/fetch-calls"
import setTwilioWebhooks from "../../api/queue/set-twilio-webhooks"
const Body = z.object({
phoneNumberSid: z.string(),
})
export default resolver.pipe(
resolver.zod(Body),
resolver.authorize(),
async ({ phoneNumberSid }, context) => {
const customer = await getCurrentCustomer(null, context)
const customerId = customer!.id
const phoneNumbers = await twilio(
customer!.accountSid!,
customer!.authToken!
).incomingPhoneNumbers.list()
const phoneNumber = phoneNumbers.find((phoneNumber) => phoneNumber.sid === phoneNumberSid)!
await db.phoneNumber.create({
data: {
customerId,
phoneNumberSid,
phoneNumber: phoneNumber.phoneNumber,
},
})
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

@ -0,0 +1,26 @@
import { resolver } from "blitz"
import { z } from "zod"
import db from "../../../db"
import getCurrentCustomer from "../../customers/queries/get-current-customer"
const Body = z.object({
twilioAccountSid: z.string(),
twilioAuthToken: z.string(),
})
export default resolver.pipe(
resolver.zod(Body),
resolver.authorize(),
async ({ twilioAccountSid, twilioAuthToken }, context) => {
const customer = await getCurrentCustomer(null, context)
const customerId = customer!.id
await db.customer.update({
where: { id: customerId },
data: {
accountSid: twilioAccountSid,
authToken: twilioAuthToken,
},
})
}
)

View File

@ -0,0 +1,23 @@
import type { BlitzPage } from "blitz"
import OnboardingLayout from "../../components/onboarding-layout"
import useCurrentCustomer from "../../../core/hooks/use-current-customer"
const StepOne: BlitzPage = () => {
useCurrentCustomer() // preload for step two
return (
<OnboardingLayout
currentStep={1}
next={{ href: "/welcome/step-two", label: "Set up your phone number" }}
>
<div className="flex flex-col space-y-4 items-center">
<span>Welcome, lets set up your virtual phone!</span>
</div>
</OnboardingLayout>
)
}
StepOne.authenticate = true
export default StepOne

View File

@ -0,0 +1,124 @@
import type { BlitzPage, GetServerSideProps } from "blitz"
import { Routes, getSession, useRouter, useMutation } from "blitz"
import { useEffect } from "react"
import twilio from "twilio"
import { useForm } from "react-hook-form"
import clsx from "clsx"
import db from "../../../../db"
import OnboardingLayout from "../../components/onboarding-layout"
import setPhoneNumber from "../../mutations/set-phone-number"
type PhoneNumber = {
phoneNumber: string
sid: string
}
type Props = {
availablePhoneNumbers: PhoneNumber[]
}
type Form = {
phoneNumberSid: string
}
const StepThree: BlitzPage<Props> = ({ availablePhoneNumbers }) => {
const {
register,
handleSubmit,
setValue,
formState: { isSubmitting },
} = useForm<Form>()
const router = useRouter()
const [setPhoneNumberMutation] = useMutation(setPhoneNumber)
useEffect(() => {
if (availablePhoneNumbers[0]) {
setValue("phoneNumberSid", availablePhoneNumbers[0].sid)
}
})
const onSubmit = handleSubmit(async ({ phoneNumberSid }) => {
if (isSubmitting) {
return
}
await setPhoneNumberMutation({ phoneNumberSid })
await router.push(Routes.Messages())
})
return (
<OnboardingLayout currentStep={3} previous={{ href: "/welcome/step-two", label: "Back" }}>
<div className="flex flex-col space-y-4 items-center">
<form onSubmit={onSubmit}>
<label
htmlFor="phoneNumberSid"
className="block text-sm font-medium text-gray-700"
>
Phone number
</label>
<select
id="phoneNumberSid"
className="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm rounded-md"
{...register("phoneNumberSid")}
>
{availablePhoneNumbers.map(({ sid, phoneNumber }) => (
<option value={sid} key={sid}>
{phoneNumber}
</option>
))}
</select>
<button
type="submit"
className={clsx(
"max-w-[240px] mt-6 mx-auto w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 text-base font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:text-sm",
!isSubmitting && "bg-primary-600 hover:bg-primary-700",
isSubmitting && "bg-primary-400 cursor-not-allowed"
)}
>
Save
</button>
</form>
</div>
</OnboardingLayout>
)
}
StepThree.authenticate = true
export const getServerSideProps: GetServerSideProps<Props> = async ({ req, res }) => {
const session = await getSession(req, res)
const customer = await db.customer.findFirst({ where: { id: session.userId! } })
if (!customer) {
return {
redirect: {
destination: Routes.StepOne().pathname,
permanent: false,
},
}
}
if (!customer.accountSid || !customer.authToken) {
return {
redirect: {
destination: Routes.StepTwo().pathname,
permanent: false,
},
}
}
const incomingPhoneNumbers = await twilio(
customer.accountSid,
customer.authToken
).incomingPhoneNumbers.list()
const phoneNumbers = incomingPhoneNumbers.map(({ phoneNumber, sid }) => ({ phoneNumber, sid }))
return {
props: {
availablePhoneNumbers: phoneNumbers,
},
}
}
export default StepThree

View File

@ -0,0 +1,103 @@
import type { BlitzPage } from "blitz"
import { Routes, useMutation, useRouter } from "blitz"
import clsx from "clsx"
import { useEffect } from "react"
import { useForm } from "react-hook-form"
import OnboardingLayout from "../../components/onboarding-layout"
import useCurrentCustomer from "../../../core/hooks/use-current-customer"
import setTwilioApiFields from "../../mutations/set-twilio-api-fields"
type Form = {
twilioAccountSid: string
twilioAuthToken: string
}
const StepTwo: BlitzPage = () => {
const {
register,
handleSubmit,
setValue,
formState: { isSubmitting },
} = useForm<Form>()
const router = useRouter()
const { customer } = useCurrentCustomer()
const [setTwilioApiFieldsMutation] = useMutation(setTwilioApiFields)
const initialAuthToken = customer?.authToken ?? ""
const initialAccountSid = customer?.accountSid ?? ""
const hasTwilioCredentials = initialAccountSid.length > 0 && initialAuthToken.length > 0
useEffect(() => {
setValue("twilioAuthToken", initialAuthToken)
setValue("twilioAccountSid", initialAccountSid)
}, [initialAuthToken, initialAccountSid])
const onSubmit = handleSubmit(async ({ twilioAccountSid, twilioAuthToken }) => {
if (isSubmitting) {
return
}
await setTwilioApiFieldsMutation({
twilioAccountSid,
twilioAuthToken,
})
await router.push(Routes.StepThree())
})
return (
<OnboardingLayout
currentStep={2}
next={hasTwilioCredentials ? { href: "/welcome/step-three", label: "Next" } : undefined}
previous={{ href: "/welcome/step-one", label: "Back" }}
>
<div className="flex flex-col space-y-4 items-center">
<form onSubmit={onSubmit} className="flex flex-col gap-6">
<div className="w-full">
<label
htmlFor="twilioAccountSid"
className="block text-sm font-medium text-gray-700"
>
Twilio Account SID
</label>
<input
type="text"
id="twilioAccountSid"
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
{...register("twilioAccountSid", { required: true })}
/>
</div>
<div className="w-full">
<label
htmlFor="twilioAuthToken"
className="block text-sm font-medium text-gray-700"
>
Twilio Auth Token
</label>
<input
type="text"
id="twilioAuthToken"
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
{...register("twilioAuthToken", { required: true })}
/>
</div>
<button
type="submit"
className={clsx(
"max-w-[240px] mx-auto w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 text-base font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:text-sm",
!isSubmitting && "bg-primary-600 hover:bg-primary-700",
isSubmitting && "bg-primary-400 cursor-not-allowed"
)}
>
Save
</button>
</form>
</div>
</OnboardingLayout>
)
}
StepTwo.authenticate = true
export default StepTwo

19
app/pages/404.tsx Normal file
View File

@ -0,0 +1,19 @@
import { Head, ErrorComponent } from "blitz"
// ------------------------------------------------------
// This page is rendered if a route match is not found
// ------------------------------------------------------
export default function Page404() {
const statusCode = 404
const title = "This page could not be found"
return (
<>
<Head>
<title>
{statusCode}: {title}
</title>
</Head>
<ErrorComponent statusCode={statusCode} title={title} />
</>
)
}

49
app/pages/_app.tsx Normal file
View File

@ -0,0 +1,49 @@
import { Suspense } from "react"
import {
AppProps,
ErrorBoundary,
ErrorComponent,
AuthenticationError,
AuthorizationError,
ErrorFallbackProps,
useQueryErrorResetBoundary,
} from "blitz"
import LoginForm from "../auth/components/login-form"
import "app/core/styles/index.css"
export default function App({ Component, pageProps }: AppProps) {
const getLayout = Component.getLayout || ((page) => page)
return (
<ErrorBoundary
FallbackComponent={RootErrorFallback}
onReset={useQueryErrorResetBoundary().reset}
>
<Suspense fallback="Silence, ca pousse">
{getLayout(<Component {...pageProps} />)}
</Suspense>
</ErrorBoundary>
)
}
function RootErrorFallback({ error, resetErrorBoundary }: ErrorFallbackProps) {
if (error instanceof AuthenticationError) {
return <LoginForm onSuccess={resetErrorBoundary} />
} else if (error instanceof AuthorizationError) {
return (
<ErrorComponent
statusCode={error.statusCode}
title="Sorry, you are not authorized to access this"
/>
)
} else {
return (
<ErrorComponent
statusCode={error.statusCode || 400}
title={error.message || error.name}
/>
)
}
}

23
app/pages/_document.tsx Normal file
View File

@ -0,0 +1,23 @@
import { Document, Html, DocumentHead, Main, BlitzScript /*DocumentContext*/ } from "blitz"
class MyDocument extends Document {
// Only uncomment if you need to customize this behaviour
// static async getInitialProps(ctx: DocumentContext) {
// const initialProps = await Document.getInitialProps(ctx)
// return {...initialProps}
// }
render() {
return (
<Html lang="en">
<DocumentHead />
<body>
<Main />
<BlitzScript />
</body>
</Html>
)
}
}
export default MyDocument

39
app/pages/index.test.tsx Normal file
View File

@ -0,0 +1,39 @@
import { render } from "../../test/utils"
import Home from "./index"
import useCurrentCustomer from "../core/hooks/use-current-customer"
jest.mock("../core/hooks/use-current-customer")
const mockUseCurrentCustomer = useCurrentCustomer as jest.MockedFunction<typeof useCurrentCustomer>
test.skip("renders blitz documentation link", () => {
// This is an example of how to ensure a specific item is in the document
// But it's disabled by default (by test.skip) so the test doesn't fail
// when you remove the the default content from the page
// This is an example on how to mock api hooks when testing
mockUseCurrentCustomer.mockReturnValue({
customer: {
id: uuidv4(),
encryptionKey: "",
accountSid: null,
authToken: null,
twimlAppSid: null,
paddleCustomerId: null,
paddleSubscriptionId: null,
user: {} as any,
},
hasCompletedOnboarding: false,
})
const { getByText } = render(<Home />)
const linkElement = getByText(/Documentation/i)
expect(linkElement).toBeInTheDocument()
})
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)
})
}

273
app/pages/index.tsx Normal file
View File

@ -0,0 +1,273 @@
import { Suspense } from "react"
import { Link, BlitzPage, useMutation, Routes } from "blitz"
import BaseLayout from "../core/layouts/base-layout"
import logout from "../auth/mutations/logout"
import useCurrentCustomer from "../core/hooks/use-current-customer"
/*
* This file is just for a pleasant getting started page for your new app.
* You can delete everything in here and start from scratch if you like.
*/
const UserInfo = () => {
const { customer } = useCurrentCustomer()
const [logoutMutation] = useMutation(logout)
if (customer) {
return (
<>
<button
className="button small"
onClick={async () => {
await logoutMutation()
}}
>
Logout
</button>
<div>
User id: <code>{customer.id}</code>
<br />
User role: <code>{customer.encryptionKey}</code>
</div>
</>
)
} else {
return (
<>
<Link href={Routes.SignupPage()}>
<a className="button small">
<strong>Sign Up</strong>
</a>
</Link>
<Link href={Routes.LoginPage()}>
<a className="button small">
<strong>Login</strong>
</a>
</Link>
</>
)
}
}
const Home: BlitzPage = () => {
return (
<div className="container">
<main>
<div className="logo">
<img src="/logo.png" alt="blitz.js" />
</div>
<p>
<strong>Congrats!</strong> Your app is ready, including user sign-up and log-in.
</p>
<div className="buttons" style={{ marginTop: "1rem", marginBottom: "1rem" }}>
<Suspense fallback="Loading...">
<UserInfo />
</Suspense>
</div>
<p>
<strong>
To add a new model to your app, <br />
run the following in your terminal:
</strong>
</p>
<pre>
<code>blitz generate all project name:string</code>
</pre>
<div style={{ marginBottom: "1rem" }}>(And select Yes to run prisma migrate)</div>
<div>
<p>
Then <strong>restart the server</strong>
</p>
<pre>
<code>Ctrl + c</code>
</pre>
<pre>
<code>blitz dev</code>
</pre>
<p>
and go to{" "}
<Link href="/projects">
<a>/projects</a>
</Link>
</p>
</div>
<div className="buttons" style={{ marginTop: "5rem" }}>
<a
className="button"
href="https://blitzjs.com/docs/getting-started?utm_source=blitz-new&utm_medium=app-template&utm_campaign=blitz-new"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
<a
className="button-outline"
href="https://github.com/blitz-js/blitz"
target="_blank"
rel="noopener noreferrer"
>
Github Repo
</a>
<a
className="button-outline"
href="https://discord.blitzjs.com"
target="_blank"
rel="noopener noreferrer"
>
Discord Community
</a>
</div>
</main>
<footer>
<a
href="https://blitzjs.com?utm_source=blitz-new&utm_medium=app-template&utm_campaign=blitz-new"
target="_blank"
rel="noopener noreferrer"
>
Powered by Blitz.js
</a>
</footer>
<style jsx global>{`
@import url("https://fonts.googleapis.com/css2?family=Libre+Franklin:wght@300;700&display=swap");
html,
body {
padding: 0;
margin: 0;
font-family: "Libre Franklin", -apple-system, BlinkMacSystemFont, Segoe UI,
Roboto, Oxygen, Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue,
sans-serif;
}
* {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
box-sizing: border-box;
}
.container {
min-height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
main {
padding: 5rem 0;
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
main p {
font-size: 1.2rem;
}
p {
text-align: center;
}
footer {
width: 100%;
height: 60px;
border-top: 1px solid #eaeaea;
display: flex;
justify-content: center;
align-items: center;
background-color: #45009d;
}
footer a {
display: flex;
justify-content: center;
align-items: center;
}
footer a {
color: #f4f4f4;
text-decoration: none;
}
.logo {
margin-bottom: 2rem;
}
.logo img {
width: 300px;
}
.buttons {
display: grid;
grid-auto-flow: column;
grid-gap: 0.5rem;
}
.button {
font-size: 1rem;
background-color: #6700eb;
padding: 1rem 2rem;
color: #f4f4f4;
text-align: center;
}
.button.small {
padding: 0.5rem 1rem;
}
.button:hover {
background-color: #45009d;
}
.button-outline {
border: 2px solid #6700eb;
padding: 1rem 2rem;
color: #6700eb;
text-align: center;
}
.button-outline:hover {
border-color: #45009d;
color: #45009d;
}
pre {
background: #fafafa;
border-radius: 5px;
padding: 0.75rem;
text-align: center;
}
code {
font-size: 0.9rem;
font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono,
Bitstream Vera Sans Mono, Courier New, monospace;
}
.grid {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
max-width: 800px;
margin-top: 3rem;
}
@media (max-width: 600px) {
.grid {
width: 100%;
flex-direction: column;
}
}
`}</style>
</div>
)
}
Home.suppressFirstRenderFlicker = true
Home.getLayout = (page) => <BaseLayout title="Home">{page}</BaseLayout>
export default Home

View File

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

View File

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

View File

@ -0,0 +1,24 @@
import { Direction } from "../../../db"
import usePhoneCalls from "../hooks/use-phone-calls"
export default function PhoneCallsList() {
const phoneCalls = usePhoneCalls()
if (phoneCalls.length === 0) {
return <div>empty state</div>
}
return (
<ul className="divide-y">
{phoneCalls.map((phoneCall) => {
const recipient = 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>
)
}

View File

@ -0,0 +1,15 @@
import { useQuery } from "blitz"
import useCurrentCustomer from "../../core/hooks/use-current-customer"
import getPhoneCalls from "../queries/get-phone-calls"
export default function usePhoneCalls() {
const { customer } = useCurrentCustomer()
if (!customer) {
throw new Error("customer not found")
}
const { phoneCalls } = useQuery(getPhoneCalls, { customerId: customer.id })[0]
return phoneCalls
}

View File

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

View File

@ -0,0 +1,32 @@
import { paginate, resolver } from "blitz"
import db, { Prisma, Customer } from "db"
interface GetPhoneCallsInput
extends Pick<Prisma.PhoneCallFindManyArgs, "where" | "orderBy" | "skip" | "take"> {
customerId: Customer["id"]
}
export default resolver.pipe(
resolver.authorize(),
async ({ where, orderBy, skip = 0, take = 100 }: GetPhoneCallsInput) => {
// TODO: in multi-tenant app, you must add validation to ensure correct tenant
const {
items: phoneCalls,
hasMore,
nextPage,
count,
} = await paginate({
skip,
take,
count: () => db.phoneCall.count({ where }),
query: (paginateArgs) => db.phoneCall.findMany({ ...paginateArgs, where, orderBy }),
})
return {
phoneCalls,
nextPage,
hasMore,
count,
}
}
)

View File

@ -0,0 +1,16 @@
import { resolver } from "blitz"
import db from "db"
import getCurrentCustomer from "../../customers/queries/get-current-customer"
export default resolver.pipe(resolver.authorize(), async (_ = null, context) => {
const customer = await getCurrentCustomer(null, context)
return db.phoneNumber.findFirst({
where: { customerId: customer!.id },
select: {
id: true,
phoneNumber: true,
phoneNumberSid: true,
},
})
})

View File

@ -0,0 +1,19 @@
import { resolver } from "blitz"
import db from "db"
import { z } from "zod"
const GetCustomerPhoneNumber = z.object({
// This accepts type of undefined, but is required at runtime
customerId: z.string().optional().refine(Boolean, "Required"),
})
export default resolver.pipe(resolver.zod(GetCustomerPhoneNumber), async ({ customerId }) =>
db.phoneNumber.findFirst({
where: { customerId },
select: {
id: true,
phoneNumber: true,
phoneNumberSid: true,
},
})
)