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

@ -1,8 +0,0 @@
{
"presets": [
"next/babel"
],
"plugins": [
"superjson-next"
]
}

11
.editorconfig Normal file
View File

@ -0,0 +1,11 @@
# https://EditorConfig.org
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = tab
insert_final_newline = true
trim_trailing_whitespace = true

View File

@ -1,3 +0,0 @@
{
"extends": "next"
}

3
.eslintrc.js Normal file
View File

@ -0,0 +1,3 @@
module.exports = {
extends: ["blitz"],
}

58
.gitignore vendored
View File

@ -1,7 +1,53 @@
.next/* # dependencies
node_modules/* node_modules
.idea/* .yarn/cache
build/* .yarn/unplugged
.env .yarn/build-state.yml
coverage/ .pnp.*
.npm
web_modules/
# blitz
/.blitz/
/.next/
*.sqlite
*.sqlite-journal
.now
.blitz**
blitz-log.log
# misc
.DS_Store
# local env files
.env.local
.env.*.local
.envrc
# Logs
logs
*.log
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Testing
.coverage
*.lcov
.nyc_output
lib-cov
# Caches
*.tsbuildinfo
.eslintcache
.node_repl_history
.yarn-integrity
# Serverless directories
.serverless/
# Stores VSCode versions used for testing VSCode extensions
.vscode-test

1
.husky/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
_

5
.husky/pre-commit Executable file
View File

@ -0,0 +1,5 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npx lint-staged
npx pretty-quick --staged

6
.husky/pre-push Executable file
View File

@ -0,0 +1,6 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npx tsc
npm run lint
npm run test

5
.idea/.gitignore generated vendored Normal file
View File

@ -0,0 +1,5 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/

8
.idea/modules.xml generated Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/virtual-phone.blitz.iml" filepath="$PROJECT_DIR$/.idea/virtual-phone.blitz.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

13
.idea/virtual-phone.blitz.iml generated Normal file
View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/temp" />
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
<excludeFolder url="file://$MODULE_DIR$/tmp" />
<excludeFolder url="file://$MODULE_DIR$/.blitz" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

2
.npmrc Normal file
View File

@ -0,0 +1,2 @@
save-exact=true
legacy-peer-deps=true

7
.prettierignore Normal file
View File

@ -0,0 +1,7 @@
.gitkeep
.env*
*.ico
*.lock
db/migrations
.next
.blitz

View File

@ -1,11 +0,0 @@
language: node_js
node_js:
- node
- 'lts/*'
cache: npm
script:
- npm run build
- npm run test

12
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,12 @@
{
"recommendations": [
"dbaeumer.vscode-eslint",
"editorconfig.editorconfig",
"esbenp.prettier-vscode",
"mikestead.dotenv",
"mgmcdermott.vscode-language-babel",
"orta.vscode-jest",
"prisma.prisma"
],
"unwantedRecommendations": []
}

7
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,7 @@
{
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
}
}

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

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

@ -1,26 +1,23 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react"
import Link from "next/link"; import Link from "next/link"
import { useRouter } from "next/router"; import { useRouter } from "next/router"
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
import { import {
faPhoneAlt as fasPhone, faPhoneAlt as fasPhone,
faTh as fasTh, faTh as fasTh,
faComments as fasComments, faComments as fasComments,
faCog as fasCog, faCog as fasCog,
} from "@fortawesome/pro-solid-svg-icons"; } from "@fortawesome/pro-solid-svg-icons"
import { import {
faPhoneAlt as farPhone, faPhoneAlt as farPhone,
faTh as farTh, faTh as farTh,
faComments as farComments, faComments as farComments,
faCog as farCog, faCog as farCog,
} from "@fortawesome/pro-regular-svg-icons"; } from "@fortawesome/pro-regular-svg-icons"
export default function Footer() { export default function Footer() {
return ( return (
<footer <footer className="grid grid-cols-4" style={{ flex: "0 0 50px" }}>
className="grid grid-cols-4"
style={{ flex: "0 0 50px" }}
>
<NavLink <NavLink
label="Calls" label="Calls"
path="/calls" path="/calls"
@ -54,22 +51,22 @@ export default function Footer() {
}} }}
/> />
</footer> </footer>
); )
} }
type NavLinkProps = { type NavLinkProps = {
path: string; path: string
label: string; label: string
icons: { icons: {
active: ReactNode; active: ReactNode
inactive: ReactNode; inactive: ReactNode
}; }
} }
function NavLink({ path, label, icons }: NavLinkProps) { function NavLink({ path, label, icons }: NavLinkProps) {
const router = useRouter(); const router = useRouter()
const isActiveRoute = router.pathname.startsWith(path); const isActiveRoute = router.pathname.startsWith(path)
const icon = isActiveRoute ? icons.active : icons.inactive; const icon = isActiveRoute ? icons.active : icons.inactive
return ( return (
<div className="flex flex-col items-center justify-around h-full"> <div className="flex flex-col items-center justify-around h-full">
@ -80,5 +77,5 @@ function NavLink({ path, label, icons }: NavLinkProps) {
</a> </a>
</Link> </Link>
</div> </div>
); )
} }

View File

@ -1,24 +1,26 @@
import type { ErrorInfo, FunctionComponent } from "react"; import type { ErrorInfo, FunctionComponent } from "react"
import { Component } from "react"; import { Component } from "react"
import Head from "next/head"; import Head from "next/head"
import type { WithRouterProps } from "next/dist/client/with-router"; import type { WithRouterProps } from "next/dist/client/with-router"
import { withRouter } from "next/router"; import { withRouter } from "next/router"
import appLogger from "../../../lib/logger"; import appLogger from "../../../../integrations/logger"
import Footer from "./footer"; import Footer from "./footer"
type Props = { type Props = {
title: string; title: string
pageTitle?: string; pageTitle?: string
}; hideFooter?: true
}
const logger = appLogger.child({ module: "Layout" }); const logger = appLogger.child({ module: "Layout" })
const Layout: FunctionComponent<Props> = ({ const Layout: FunctionComponent<Props> = ({
children, children,
title, title,
pageTitle = title, pageTitle = title,
hideFooter = false,
}) => { }) => {
return ( return (
<> <>
@ -35,37 +37,37 @@ const Layout: FunctionComponent<Props> = ({
<ErrorBoundary>{children}</ErrorBoundary> <ErrorBoundary>{children}</ErrorBoundary>
</main> </main>
</div> </div>
<Footer /> {!hideFooter ? <Footer /> : null}
</div> </div>
</div> </div>
</> </>
); )
}; }
type ErrorBoundaryState = type ErrorBoundaryState =
| { | {
isError: false; isError: false
} }
| { | {
isError: true; isError: true
errorMessage: string; errorMessage: string
}; }
const ErrorBoundary = withRouter( const ErrorBoundary = withRouter(
class ErrorBoundary extends Component<WithRouterProps, ErrorBoundaryState> { class ErrorBoundary extends Component<WithRouterProps, ErrorBoundaryState> {
public readonly state = { public readonly state = {
isError: false, isError: false,
} as const; } as const
static getDerivedStateFromError(error: Error): ErrorBoundaryState { static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { return {
isError: true, isError: true,
errorMessage: error.message, errorMessage: error.message,
}; }
} }
public componentDidCatch(error: Error, errorInfo: ErrorInfo) { public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
logger.error(error, errorInfo.componentStack); logger.error(error, errorInfo.componentStack)
} }
public render() { public render() {
@ -88,12 +90,12 @@ const ErrorBoundary = withRouter(
? ?
</p> </p>
</> </>
); )
} }
return this.props.children; return this.props.children
} }
}, }
); )
export default Layout; export default Layout

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

@ -1,71 +1,73 @@
import type { FunctionComponent } from "react"; import type { FunctionComponent } from "react"
import { CheckIcon } from "@heroicons/react/solid"; import { CheckIcon } from "@heroicons/react/solid"
import clsx from "clsx"; import clsx from "clsx"
import Link from "next/link"; import { Link, Routes, useRouter } from "blitz"
import useCustomerPhoneNumber from "../../core/hooks/use-customer-phone-number"
type StepLink = { type StepLink = {
href: string; href: string
label: string; label: string
} }
type Props = { type Props = {
currentStep: 1 | 2 | 3; currentStep: 1 | 2 | 3
previous?: StepLink; previous?: StepLink
next?: StepLink; next?: StepLink
}; }
const steps = [ const steps = ["Welcome", "Twilio Credentials", "Pick a plan"] as const
"Welcome",
"Twilio Credentials", const OnboardingLayout: FunctionComponent<Props> = ({ children, currentStep, previous, next }) => {
"Pick a plan", const router = useRouter()
] as const; const customerPhoneNumber = useCustomerPhoneNumber()
if (customerPhoneNumber) {
throw router.push(Routes.Messages())
}
const OnboardingLayout: FunctionComponent<Props> = ({
children,
currentStep,
previous,
next,
}) => {
return ( return (
<div className="bg-gray-800 fixed z-10 inset-0 overflow-y-auto"> <div className="bg-gray-800 fixed z-10 inset-0 overflow-y-auto">
<div className="min-h-screen text-center block p-0"> <div className="min-h-screen text-center block p-0">
{/* This element is to trick the browser into centering the modal contents. */} {/* This element is to trick the browser into centering the modal contents. */}
<span className="inline-block align-middle h-screen"> <span className="inline-block align-middle h-screen">&#8203;</span>
&#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"> <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> <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> <section className="px-6 pt-6 pb-12">{children}</section>
<nav className="grid grid-cols-1 gap-y-3 mx-auto"> <nav className="grid grid-cols-1 gap-y-3 mx-auto">
{ {next ? (
next ? ( <Link href={next.href}>
<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">
<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}
{next.label} </a>
</a> </Link>
</Link> ) : null}
) : null
}
{ {previous ? (
previous ? ( <Link href={previous.href}>
<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">
<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}
{previous.label} </a>
</a> </Link>
</Link> ) : null}
) : null
}
<ol className="flex items-center"> <ol className="flex items-center">
{steps.map((step, stepIdx) => { {steps.map((step, stepIdx) => {
const isComplete = currentStep > stepIdx + 1; const isComplete = currentStep > stepIdx + 1
const isCurrent = stepIdx + 1 === currentStep; const isCurrent = stepIdx + 1 === currentStep
return ( return (
<li key={step} className={clsx(stepIdx !== steps.length - 1 ? "pr-20 sm:pr-32" : "", "relative")}> <li
key={step}
className={clsx(
stepIdx !== steps.length - 1 ? "pr-20 sm:pr-32" : "",
"relative"
)}
>
{isComplete ? ( {isComplete ? (
<> <>
<div className="absolute inset-0 flex items-center"> <div className="absolute inset-0 flex items-center">
@ -105,7 +107,7 @@ const OnboardingLayout: FunctionComponent<Props> = ({
</div> </div>
</div> </div>
</div> </div>
); )
}; }
export default OnboardingLayout; 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

@ -1,47 +1,49 @@
import type { InferGetServerSidePropsType, NextPage } from "next"; import type { BlitzPage } from "blitz"
import { useRouter } from "next/router"; import { Routes, useMutation, useRouter } from "blitz"
import { useEffect } from "react"; import clsx from "clsx"
import { useForm } from "react-hook-form"; import { useEffect } from "react"
import axios from "axios"; import { useForm } from "react-hook-form"
import { withPageAuthRequired } from "../../../lib/session-helpers"; import OnboardingLayout from "../../components/onboarding-layout"
import OnboardingLayout from "../../components/welcome/onboarding-layout"; import useCurrentCustomer from "../../../core/hooks/use-current-customer"
import clsx from "clsx"; import setTwilioApiFields from "../../mutations/set-twilio-api-fields"
import { findCustomer } from "../../database/customer";
type Props = InferGetServerSidePropsType<typeof getServerSideProps>;
type Form = { type Form = {
twilioAccountSid: string; twilioAccountSid: string
twilioAuthToken: string; twilioAuthToken: string
} }
const StepTwo: NextPage<Props> = ({ accountSid, authToken }) => { const StepTwo: BlitzPage = () => {
const { const {
register, register,
handleSubmit, handleSubmit,
setValue, setValue,
formState: { isSubmitting }, formState: { isSubmitting },
} = useForm<Form>(); } = useForm<Form>()
const router = useRouter(); 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(() => { useEffect(() => {
setValue("twilioAuthToken", authToken); setValue("twilioAuthToken", initialAuthToken)
setValue("twilioAccountSid", accountSid); setValue("twilioAccountSid", initialAccountSid)
}); }, [initialAuthToken, initialAccountSid])
const onSubmit = handleSubmit(async ({ twilioAccountSid, twilioAuthToken }) => { const onSubmit = handleSubmit(async ({ twilioAccountSid, twilioAuthToken }) => {
if (isSubmitting) { if (isSubmitting) {
return; return
} }
await axios.post("/api/user/update-user", { await setTwilioApiFieldsMutation({
twilioAccountSid, twilioAccountSid,
twilioAuthToken, twilioAuthToken,
}, { withCredentials: true }); })
await router.push("/welcome/step-three");
}); await router.push(Routes.StepThree())
const hasTwilioCredentials = accountSid.length > 0 && authToken.length > 0; })
return ( return (
<OnboardingLayout <OnboardingLayout
@ -52,7 +54,10 @@ const StepTwo: NextPage<Props> = ({ accountSid, authToken }) => {
<div className="flex flex-col space-y-4 items-center"> <div className="flex flex-col space-y-4 items-center">
<form onSubmit={onSubmit} className="flex flex-col gap-6"> <form onSubmit={onSubmit} className="flex flex-col gap-6">
<div className="w-full"> <div className="w-full">
<label htmlFor="twilioAccountSid" className="block text-sm font-medium text-gray-700"> <label
htmlFor="twilioAccountSid"
className="block text-sm font-medium text-gray-700"
>
Twilio Account SID Twilio Account SID
</label> </label>
<input <input
@ -63,7 +68,10 @@ const StepTwo: NextPage<Props> = ({ accountSid, authToken }) => {
/> />
</div> </div>
<div className="w-full"> <div className="w-full">
<label htmlFor="twilioAuthToken" className="block text-sm font-medium text-gray-700"> <label
htmlFor="twilioAuthToken"
className="block text-sm font-medium text-gray-700"
>
Twilio Auth Token Twilio Auth Token
</label> </label>
<input <input
@ -79,7 +87,7 @@ const StepTwo: NextPage<Props> = ({ accountSid, authToken }) => {
className={clsx( 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", "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-600 hover:bg-primary-700",
isSubmitting && "bg-primary-400 cursor-not-allowed", isSubmitting && "bg-primary-400 cursor-not-allowed"
)} )}
> >
Save Save
@ -87,18 +95,9 @@ const StepTwo: NextPage<Props> = ({ accountSid, authToken }) => {
</form> </form>
</div> </div>
</OnboardingLayout> </OnboardingLayout>
); )
}; }
export const getServerSideProps = withPageAuthRequired(async (context, user) => { StepTwo.authenticate = true
const customer = await findCustomer(user.id);
return { export default StepTwo
props: {
accountSid: customer.accountSid ?? "",
authToken: customer.authToken ?? "",
},
};
});
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,
},
})
)

4
babel.config.js Normal file
View File

@ -0,0 +1,4 @@
module.exports = {
presets: ["blitz/babel"],
plugins: [],
}

36
blitz.config.ts Normal file
View File

@ -0,0 +1,36 @@
import { BlitzConfig, sessionMiddleware, simpleRolesIsAuthorized } from "blitz"
const config: BlitzConfig = {
middleware: [
sessionMiddleware({
cookiePrefix: "virtual-phone-blitz",
isAuthorized: simpleRolesIsAuthorized,
}),
],
serverRuntimeConfig: {
paddle: {
apiKey: process.env.PADDLE_API_KEY,
publicKey: process.env.PADDLE_PUBLIC_KEY,
},
awsSes: {
awsRegion: process.env.AWS_SES_REGION,
accessKeyId: process.env.AWS_SES_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SES_ACCESS_KEY_SECRET,
fromEmail: process.env.AWS_SES_FROM_EMAIL,
},
mailChimp: {
apiKey: process.env.MAILCHIMP_API_KEY,
audienceId: process.env.MAILCHIMP_AUDIENCE_ID,
},
masterEncryptionKey: process.env.MASTER_ENCRYPTION_KEY,
},
/* Uncomment this to customize the webpack config
webpack: (config, { buildId, dev, isServer, defaultLoaders, webpack }) => {
// Note: we provide webpack above so you should not `require` it
// Perform customizations to webpack config
// Important: return the modified config
return config
},
*/
}
module.exports = config

37
db/_encryption.ts Normal file
View File

@ -0,0 +1,37 @@
import crypto from "crypto"
import { getConfig } from "blitz"
const { serverRuntimeConfig } = getConfig()
const IV_LENGTH = 16
const ALGORITHM = "aes-256-cbc"
export function encrypt(text: string, encryptionKey: Buffer | string) {
const encryptionKeyAsBuffer = Buffer.isBuffer(encryptionKey)
? encryptionKey
: Buffer.from(encryptionKey, "hex")
const iv = crypto.randomBytes(IV_LENGTH)
const cipher = crypto.createCipheriv(ALGORITHM, encryptionKeyAsBuffer, iv)
const encrypted = cipher.update(text)
const encryptedBuffer = Buffer.concat([encrypted, cipher.final()])
return `${iv.toString("hex")}:${encryptedBuffer.toString("hex")}`
}
export function decrypt(encryptedHexText: string, encryptionKey: Buffer | string) {
const encryptionKeyAsBuffer = Buffer.isBuffer(encryptionKey)
? encryptionKey
: Buffer.from(encryptionKey, "hex")
const [hexIv, hexText] = encryptedHexText.split(":")
const iv = Buffer.from(hexIv!, "hex")
const encryptedText = Buffer.from(hexText!, "hex")
const decipher = crypto.createDecipheriv(ALGORITHM, encryptionKeyAsBuffer, iv)
const decrypted = decipher.update(encryptedText)
const decryptedBuffer = Buffer.concat([decrypted, decipher.final()])
return decryptedBuffer.toString()
}
export function computeEncryptionKey(userIdentifier: string) {
return crypto.scryptSync(userIdentifier, serverRuntimeConfig.masterEncryptionKey, 32)
}

7
db/index.ts Normal file
View File

@ -0,0 +1,7 @@
import { enhancePrisma } from "blitz"
import { PrismaClient } from "@prisma/client"
const EnhancedPrisma = enhancePrisma(PrismaClient)
export * from "@prisma/client"
export default new EnhancedPrisma()

View File

@ -0,0 +1,57 @@
-- CreateTable
CREATE TABLE "User" (
"id" SERIAL NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"name" TEXT,
"email" TEXT NOT NULL,
"hashedPassword" TEXT,
"role" TEXT NOT NULL DEFAULT E'USER',
PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Session" (
"id" SERIAL NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"expiresAt" TIMESTAMP(3),
"handle" TEXT NOT NULL,
"hashedSessionToken" TEXT,
"antiCSRFToken" TEXT,
"publicData" TEXT,
"privateData" TEXT,
"userId" INTEGER,
PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Token" (
"id" SERIAL NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"hashedToken" TEXT NOT NULL,
"type" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"sentTo" TEXT NOT NULL,
"userId" INTEGER NOT NULL,
PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User.email_unique" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "Session.handle_unique" ON "Session"("handle");
-- CreateIndex
CREATE UNIQUE INDEX "Token.hashedToken_type_unique" ON "Token"("hashedToken", "type");
-- AddForeignKey
ALTER TABLE "Session" ADD FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Token" ADD FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,137 @@
/*
Warnings:
- The primary key for the `Session` table will be changed. If it partially fails, the table could be left without primary key constraint.
- The primary key for the `Token` table will be changed. If it partially fails, the table could be left without primary key constraint.
- The primary key for the `User` table will be changed. If it partially fails, the table could be left without primary key constraint.
- A unique constraint covering the columns `[hashedToken,type]` on the table `Token` will be added. If there are existing duplicate values, this will fail.
- Changed the type of `type` on the `Token` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required.
*/
-- CreateEnum
CREATE TYPE "TokenType" AS ENUM ('RESET_PASSWORD');
-- CreateEnum
CREATE TYPE "Direction" AS ENUM ('Inbound', 'Outbound');
-- CreateEnum
CREATE TYPE "MessageStatus" AS ENUM ('Queued', 'Sending', 'Sent', 'Failed', 'Delivered', 'Undelivered', 'Receiving', 'Received', 'Accepted', 'Scheduled', 'Read', 'PartiallyDelivered', 'Canceled');
-- CreateEnum
CREATE TYPE "CallStatus" AS ENUM ('Queued', 'Ringing', 'InProgress', 'Completed', 'Busy', 'Failed', 'NoAnswer', 'Canceled');
-- DropForeignKey
ALTER TABLE "Session" DROP CONSTRAINT "Session_userId_fkey";
-- DropForeignKey
ALTER TABLE "Token" DROP CONSTRAINT "Token_userId_fkey";
-- AlterTable
ALTER TABLE "Session" DROP CONSTRAINT "Session_pkey",
ALTER COLUMN "id" DROP DEFAULT,
ALTER COLUMN "id" SET DATA TYPE TEXT,
ALTER COLUMN "createdAt" SET DATA TYPE TIMESTAMPTZ,
ALTER COLUMN "updatedAt" SET DATA TYPE TIMESTAMPTZ,
ALTER COLUMN "expiresAt" SET DATA TYPE TIMESTAMPTZ,
ALTER COLUMN "userId" SET DATA TYPE TEXT,
ADD PRIMARY KEY ("id");
DROP SEQUENCE "Session_id_seq";
-- AlterTable
ALTER TABLE "Token" DROP CONSTRAINT "Token_pkey",
ALTER COLUMN "id" DROP DEFAULT,
ALTER COLUMN "id" SET DATA TYPE TEXT,
ALTER COLUMN "createdAt" SET DATA TYPE TIMESTAMPTZ,
ALTER COLUMN "updatedAt" SET DATA TYPE TIMESTAMPTZ,
DROP COLUMN "type",
ADD COLUMN "type" "TokenType" NOT NULL,
ALTER COLUMN "expiresAt" SET DATA TYPE TIMESTAMPTZ,
ALTER COLUMN "userId" SET DATA TYPE TEXT,
ADD PRIMARY KEY ("id");
DROP SEQUENCE "Token_id_seq";
-- AlterTable
ALTER TABLE "User" DROP CONSTRAINT "User_pkey",
ALTER COLUMN "id" DROP DEFAULT,
ALTER COLUMN "id" SET DATA TYPE TEXT,
ALTER COLUMN "createdAt" SET DATA TYPE TIMESTAMPTZ,
ALTER COLUMN "updatedAt" SET DATA TYPE TIMESTAMPTZ,
ADD PRIMARY KEY ("id");
DROP SEQUENCE "User_id_seq";
-- CreateTable
CREATE TABLE "Customer" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ NOT NULL,
"encryptionKey" TEXT NOT NULL,
"accountSid" TEXT,
"authToken" TEXT,
"twimlAppSid" TEXT,
"paddleCustomerId" TEXT,
"paddleSubscriptionId" TEXT,
PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Message" (
"id" TEXT NOT NULL,
"sentAt" TIMESTAMPTZ NOT NULL,
"content" TEXT NOT NULL,
"from" TEXT NOT NULL,
"to" TEXT NOT NULL,
"direction" "Direction" NOT NULL,
"status" "MessageStatus" NOT NULL,
"twilioSid" TEXT,
"customerId" TEXT NOT NULL,
PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PhoneCall" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMPTZ NOT NULL,
"twilioSid" TEXT NOT NULL,
"from" TEXT NOT NULL,
"to" TEXT NOT NULL,
"status" "CallStatus" NOT NULL,
"direction" "Direction" NOT NULL,
"duration" TEXT NOT NULL,
"customerId" TEXT NOT NULL,
PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PhoneNumber" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMPTZ NOT NULL,
"phoneNumberSid" TEXT NOT NULL,
"phoneNumber" TEXT NOT NULL,
"customerId" TEXT NOT NULL,
PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Token.hashedToken_type_unique" ON "Token"("hashedToken", "type");
-- AddForeignKey
ALTER TABLE "Session" ADD FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Token" ADD FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Customer" ADD FOREIGN KEY ("id") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Message" ADD FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PhoneCall" ADD FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PhoneNumber" ADD FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,12 @@
/*
Warnings:
- The `role` column on the `User` table would be dropped and recreated. This will lead to data loss if there is data in the column.
*/
-- CreateEnum
CREATE TYPE "Role" AS ENUM ('USER', 'ADMIN');
-- AlterTable
ALTER TABLE "User" DROP COLUMN "role",
ADD COLUMN "role" "Role" NOT NULL DEFAULT E'USER';

View File

@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "PhoneCall" ALTER COLUMN "createdAt" SET DEFAULT CURRENT_TIMESTAMP;
-- AlterTable
ALTER TABLE "PhoneNumber" ALTER COLUMN "createdAt" SET DEFAULT CURRENT_TIMESTAMP;

View File

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"

154
db/schema.prisma Normal file
View File

@ -0,0 +1,154 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
datasource db {
provider = "postgres"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
// --------------------------------------
model User {
id String @id @default(uuid())
createdAt DateTime @default(now()) @db.Timestamptz
updatedAt DateTime @updatedAt @db.Timestamptz
name String?
email String @unique
hashedPassword String?
role Role @default(USER)
tokens Token[]
sessions Session[]
customer Customer[]
}
enum Role {
USER
ADMIN
}
model Session {
id String @id @default(uuid())
createdAt DateTime @default(now()) @db.Timestamptz
updatedAt DateTime @updatedAt @db.Timestamptz
expiresAt DateTime? @db.Timestamptz
handle String @unique
hashedSessionToken String?
antiCSRFToken String?
publicData String?
privateData String?
user User? @relation(fields: [userId], references: [id])
userId String?
}
model Token {
id String @id @default(uuid())
createdAt DateTime @default(now()) @db.Timestamptz
updatedAt DateTime @updatedAt @db.Timestamptz
hashedToken String
type TokenType
expiresAt DateTime @db.Timestamptz
sentTo String
user User @relation(fields: [userId], references: [id])
userId String
@@unique([hashedToken, type])
}
enum TokenType {
RESET_PASSWORD
}
model Customer {
id String @id
createdAt DateTime @default(now()) @db.Timestamptz
updatedAt DateTime @updatedAt @db.Timestamptz
encryptionKey String
accountSid String?
authToken String?
// TODO: encrypt it with encryptionKey
twimlAppSid String?
paddleCustomerId String?
paddleSubscriptionId String?
user User @relation(fields: [id], references: [id])
messages Message[]
phoneCalls PhoneCall[]
phoneNumbers PhoneNumber[]
}
model Message {
id String @id @default(uuid())
sentAt DateTime @db.Timestamptz
content String
from String
to String
direction Direction
status MessageStatus
twilioSid String?
customer Customer @relation(fields: [customerId], references: [id])
customerId String
}
enum Direction {
Inbound
Outbound
}
enum MessageStatus {
Queued
Sending
Sent
Failed
Delivered
Undelivered
Receiving
Received
Accepted
Scheduled
Read
PartiallyDelivered
Canceled
}
model PhoneCall {
id String @id @default(uuid())
createdAt DateTime @default(now()) @db.Timestamptz
twilioSid String
from String
to String
status CallStatus
direction Direction
duration String
customer Customer @relation(fields: [customerId], references: [id])
customerId String
}
enum CallStatus {
Queued
Ringing
InProgress
Completed
Busy
Failed
NoAnswer
Canceled
}
model PhoneNumber {
id String @id @default(uuid())
createdAt DateTime @default(now()) @db.Timestamptz
phoneNumberSid String
phoneNumber String
customer Customer @relation(fields: [customerId], references: [id])
customerId String
}

16
db/seeds.ts Normal file
View File

@ -0,0 +1,16 @@
// import db from "./index"
/*
* This seed function is executed when you run `blitz db seed`.
*
* Probably you want to use a library like https://chancejs.com
* or https://github.com/Marak/Faker.js to easily generate
* realistic data.
*/
const seed = async () => {
// for (let i = 0; i < 5; i++) {
// await db.project.create({ data: { name: "Project " + i } })
// }
}
export default seed

3
global.d.ts vendored Normal file
View File

@ -0,0 +1,3 @@
// These reference imports provide type definitions for things like styled-jsx and css modules
/// <reference types="next" />
/// <reference types="next/types/global" />

View File

@ -1,4 +1,4 @@
import pino from "pino"; import pino from "pino"
const appLogger = pino({ const appLogger = pino({
level: "debug", level: "debug",
@ -7,6 +7,6 @@ const appLogger = pino({
revision: process.env.VERCEL_GITHUB_COMMIT_SHA, revision: process.env.VERCEL_GITHUB_COMMIT_SHA,
}, },
prettyPrint: true, prettyPrint: true,
}); })
export default appLogger; export default appLogger

View File

@ -1,15 +1,3 @@
module.exports = { module.exports = {
collectCoverageFrom: [ preset: "blitz",
"src/**/*.{js,jsx,ts,tsx}", }
"lib/**/*.{js,jsx,ts,tsx}",
],
transform: {
"^.+\\.(js|jsx|ts|tsx)$": "<rootDir>/node_modules/babel-jest",
},
transformIgnorePatterns: [
"/node_modules/",
"/.next/",
],
setupFilesAfterEnv: ["./jest/setup.ts"],
testEnvironment: "node",
};

View File

@ -1,104 +0,0 @@
import type { NextApiHandler } from "next";
import type { IncomingMessage, RequestListener, ServerResponse } from "http";
import http from "http";
import type { __ApiPreviewProps } from "next/dist/next-server/server/api-utils";
import { apiResolver } from "next/dist/next-server/server/api-utils";
import listen from "test-listen";
import fetch from "isomorphic-fetch";
import crypto from "crypto";
type Authentication =
| "none"
| "auth0"
| "google-oauth2"
| "facebook"
| "twitter";
type Params = {
method: string;
body?: any;
headers?: Record<string, string>;
query?: Record<string, string>;
authentication?: Authentication;
};
const apiPreviewProps: __ApiPreviewProps = {
previewModeEncryptionKey: crypto.randomBytes(16).toString("hex"),
previewModeId: crypto.randomBytes(32).toString("hex"),
previewModeSigningKey: crypto.randomBytes(32).toString("hex"),
};
export async function callApiHandler(handler: NextApiHandler, params: Params) {
const {
method = "GET",
body,
headers = {},
query = {},
authentication = "none",
} = params;
const requestHandler: RequestListener = (req, res) => {
const propagateError = false;
Object.assign(req.headers, headers);
if (req.url !== "/") {
// in these API tests, our http server uses the same handler for all routes, it has no idea about our app's routes
// when we're hitting anything else than the / route, it means that we've been redirected
const fallbackHandler: NextApiHandler = (req, res) =>
res.status(200).end();
return apiResolver(
req,
res,
query,
fallbackHandler,
apiPreviewProps,
propagateError,
);
}
if (authentication !== "none") {
writeSessionToCookie(req, res, authentication);
}
return apiResolver(
req,
res,
query,
handler,
apiPreviewProps,
propagateError,
);
};
const server = http.createServer(requestHandler);
const url = await listen(server);
let fetchOptions: RequestInit = { method, redirect: "manual" };
if (body) {
fetchOptions.body = JSON.stringify(body);
fetchOptions.headers = { "Content-Type": "application/json" };
}
const response = await fetch(url, fetchOptions);
server.close();
return response;
}
function writeSessionToCookie(
req: IncomingMessage,
res: ServerResponse,
authentication: Authentication,
) {
const session = {
id: `${authentication}|userId`,
email: "test@fss.test",
name: "Groot",
teamId: "teamId",
role: "owner",
};
const setCookieHeader = res.getHeader("Set-Cookie") as string[];
// write it to request headers to immediately have access to the user's session
req.headers.cookie = setCookieHeader.join("");
}

View File

@ -1,24 +0,0 @@
import "@testing-library/jest-dom/extend-expect";
jest.mock("next/config", () => () => {
// see https://github.com/vercel/next.js/issues/4024
const config = require("../next.config");
return {
serverRuntimeConfig: config.serverRuntimeConfig,
publicRuntimeConfig: config.publicRuntimeConfig,
};
});
jest.mock("../lib/logger", () => ({
child: jest.fn().mockReturnValue({
log: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
warn: jest.fn(),
}),
}));
export function noop() {
// exported function to mark the file as a module
}

View File

@ -1 +0,0 @@
export * from "@testing-library/react";

View File

@ -1,30 +0,0 @@
import type { NextApiHandler } from "next";
import { withApiAuthRequired } from "../session-helpers";
import { callApiHandler } from "../../jest/helpers";
describe("session-helpers", () => {
describe("withApiAuthRequired", () => {
const basicHandler: NextApiHandler = (req, res) =>
res.status(200).end();
test("responds 401 to unauthenticated GET", async () => {
const withAuthHandler = withApiAuthRequired(basicHandler);
const { status } = await callApiHandler(withAuthHandler, {
method: "GET",
});
expect(status).toBe(401);
});
test("responds 200 to authenticated GET", async () => {
const withAuthHandler = withApiAuthRequired(basicHandler);
const { status } = await callApiHandler(withAuthHandler, {
method: "GET",
authentication: "auth0",
});
expect(status).toBe(200);
});
});
});

View File

@ -1,183 +0,0 @@
import type {
GetServerSideProps,
GetServerSidePropsContext,
GetServerSidePropsResult,
NextApiHandler,
NextApiRequest,
NextApiResponse,
} from "next";
import type { User } from "@supabase/supabase-js";
import supabase from "../src/supabase/server";
import appLogger from "./logger";
import { setCookie } from "./utils/cookies";
import { findCustomer } from "../src/database/customer";
import { findCustomerPhoneNumber } from "../src/database/phone-number";
const logger = appLogger.child({ module: "session-helpers" });
type EmptyProps = Record<string, unknown>;
type SessionProps = {
user: User;
};
function hasProps<Props extends EmptyProps = EmptyProps>(
result: GetServerSidePropsResult<Props>,
): result is { props: Props } {
return result.hasOwnProperty("props");
}
export function withPageOnboardingRequired<Props extends EmptyProps = EmptyProps>(
getServerSideProps?: GSSPWithSession<Props>,
) {
return withPageAuthRequired(
async function wrappedGetServerSideProps(context, user) {
if (context.req.cookies.hasDoneOnboarding !== "true") {
try {
const customer = await findCustomer(user.id);
console.log("customer", customer);
if (!customer.accountSid || !customer.authToken) {
return {
redirect: {
destination: "/welcome/step-two",
permanent: false,
},
};
}
/*if (!customer.paddleCustomerId || !customer.paddleSubscriptionId) {
return {
redirect: {
destination: "/welcome/step-one",
permanent: false,
},
};
}*/
try {
await findCustomerPhoneNumber(user.id);
} catch (error) {
console.log("error", error);
return {
redirect: {
destination: "/welcome/step-three",
permanent: false,
},
};
}
setCookie({
req: context.req,
res: context.res,
name: "hasDoneOnboarding",
value: "true",
});
} catch (error) {
console.error("error", error);
}
}
if (!getServerSideProps) {
return {
props: {} as Props,
};
}
return getServerSideProps(context, user);
},
);
}
type GSSPWithSession<Props> = (
context: GetServerSidePropsContext,
user: User,
) => GetServerSidePropsResult<Props> | Promise<GetServerSidePropsResult<Props>>;
export function withPageAuthRequired<Props extends EmptyProps = EmptyProps>(
getServerSideProps?: GSSPWithSession<Props>,
): GetServerSideProps<Omit<Props, "user"> & SessionProps> {
return async function wrappedGetServerSideProps(context) {
const redirectTo = `/auth/sign-in?redirectTo=${context.resolvedUrl}`;
const userResponse = await supabase.auth.api.getUserByCookie(context.req);
const user = userResponse.user!;
if (userResponse.error) {
return {
redirect: {
destination: redirectTo,
permanent: false,
},
};
}
if (!getServerSideProps) {
return {
props: { user } as Props & SessionProps,
};
}
const start = Date.now();
const getServerSidePropsResult = await getServerSideProps(context, user);
console.log("getServerSideProps took", Date.now() - start);
if (!hasProps(getServerSidePropsResult)) {
return getServerSidePropsResult;
}
return {
props: {
...getServerSidePropsResult.props,
user,
},
};
};
}
type ApiHandlerWithAuth<T> = (
req: NextApiRequest,
res: NextApiResponse<T>,
user: User,
) => void | Promise<void>;
export function withApiAuthRequired<T = any>(
handler: ApiHandlerWithAuth<T>,
): NextApiHandler {
return async function wrappedApiHandler(req, res) {
const userResponse = await supabase.auth.api.getUserByCookie(req);
if (userResponse.error) {
logger.error(userResponse.error.message);
return res.status(401).end();
}
return handler(req, res, userResponse.user!);
};
}
export function withPageAuthNotRequired<Props extends EmptyProps = EmptyProps>(
getServerSideProps?: GetServerSideProps<Props>,
): GetServerSideProps<Props> {
return async function wrappedGetServerSideProps(context) {
let redirectTo: string;
if (Array.isArray(context.query.redirectTo)) {
redirectTo = context.query.redirectTo[0];
} else {
redirectTo = context.query.redirectTo ?? "/messages";
}
const { user } = await supabase.auth.api.getUserByCookie(context.req);
console.log("user", user);
if (user !== null) {
console.log("redirect");
return {
redirect: {
destination: redirectTo,
permanent: false,
},
};
}
console.log("no redirect");
if (getServerSideProps) {
return getServerSideProps(context);
}
return { props: {} as Props };
};
}

Some files were not shown because too many files have changed in this diff Show More