migrate to blitzjs
This commit is contained in:
parent
4aa646ab43
commit
fc4278ca7b
8
.babelrc
8
.babelrc
@ -1,8 +0,0 @@
|
||||
{
|
||||
"presets": [
|
||||
"next/babel"
|
||||
],
|
||||
"plugins": [
|
||||
"superjson-next"
|
||||
]
|
||||
}
|
11
.editorconfig
Normal file
11
.editorconfig
Normal 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
|
3
.eslintrc.js
Normal file
3
.eslintrc.js
Normal file
@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
extends: ["blitz"],
|
||||
}
|
58
.gitignore
vendored
58
.gitignore
vendored
@ -1,7 +1,53 @@
|
||||
.next/*
|
||||
node_modules/*
|
||||
.idea/*
|
||||
build/*
|
||||
.env
|
||||
coverage/
|
||||
# dependencies
|
||||
node_modules
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.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
1
.husky/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
_
|
5
.husky/pre-commit
Executable file
5
.husky/pre-commit
Executable file
@ -0,0 +1,5 @@
|
||||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
npx lint-staged
|
||||
npx pretty-quick --staged
|
6
.husky/pre-push
Executable file
6
.husky/pre-push
Executable 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
5
.idea/.gitignore
generated
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal 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
6
.idea/vcs.xml
generated
Normal 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
13
.idea/virtual-phone.blitz.iml
generated
Normal 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>
|
7
.prettierignore
Normal file
7
.prettierignore
Normal file
@ -0,0 +1,7 @@
|
||||
.gitkeep
|
||||
.env*
|
||||
*.ico
|
||||
*.lock
|
||||
db/migrations
|
||||
.next
|
||||
.blitz
|
11
.travis.yml
11
.travis.yml
@ -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
12
.vscode/extensions.json
vendored
Normal 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
7
.vscode/settings.json
vendored
Normal 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
4
app/api/_types.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export type ApiError = {
|
||||
statusCode: number
|
||||
errorMessage: string
|
||||
}
|
16
app/api/ddd.ts
Normal file
16
app/api/ddd.ts
Normal 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()
|
||||
}
|
@ -1,21 +1,21 @@
|
||||
import getConfig from "next/config";
|
||||
import axios from "axios";
|
||||
import getConfig from "next/config"
|
||||
import axios from "axios"
|
||||
|
||||
const { serverRuntimeConfig } = getConfig();
|
||||
const { serverRuntimeConfig } = getConfig()
|
||||
|
||||
export async function addSubscriber(email: string) {
|
||||
const { apiKey, audienceId } = serverRuntimeConfig.mailChimp;
|
||||
const region = apiKey.split("-")[1];
|
||||
const url = `https://${region}.api.mailchimp.com/3.0/lists/${audienceId}/members`;
|
||||
const { apiKey, audienceId } = serverRuntimeConfig.mailChimp
|
||||
const region = apiKey.split("-")[1]
|
||||
const url = `https://${region}.api.mailchimp.com/3.0/lists/${audienceId}/members`
|
||||
const data = {
|
||||
email_address: email,
|
||||
status: "subscribed",
|
||||
};
|
||||
const base64ApiKey = Buffer.from(`any:${apiKey}`).toString("base64");
|
||||
}
|
||||
const base64ApiKey = Buffer.from(`any:${apiKey}`).toString("base64")
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Basic ${base64ApiKey}`,
|
||||
};
|
||||
}
|
||||
|
||||
return axios.post(url, data, { headers });
|
||||
return axios.post(url, data, { headers })
|
||||
}
|
59
app/api/newsletter/subscribe.ts
Normal file
59
app/api/newsletter/subscribe.ts
Normal 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()
|
||||
}
|
38
app/api/queue/fetch-calls.ts
Normal file
38
app/api/queue/fetch-calls.ts
Normal 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
|
38
app/api/queue/fetch-messages.ts
Normal file
38
app/api/queue/fetch-messages.ts
Normal 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
|
59
app/api/queue/insert-calls.ts
Normal file
59
app/api/queue/insert-calls.ts
Normal 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
|
||||
}
|
||||
}
|
78
app/api/queue/insert-messages.ts
Normal file
78
app/api/queue/insert-messages.ts
Normal 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
|
||||
}
|
||||
}
|
34
app/api/queue/send-message.ts
Normal file
34
app/api/queue/send-message.ts
Normal 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
|
43
app/api/queue/set-twilio-webhooks.ts
Normal file
43
app/api/queue/set-twilio-webhooks.ts
Normal 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
|
61
app/auth/components/login-form.tsx
Normal file
61
app/auth/components/login-form.tsx
Normal 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
|
49
app/auth/components/signup-form.tsx
Normal file
49
app/auth/components/signup-form.tsx
Normal 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
|
24
app/auth/mutations/change-password.ts
Normal file
24
app/auth/mutations/change-password.ts
Normal 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
|
||||
}
|
||||
)
|
61
app/auth/mutations/forgot-password.test.ts
Normal file
61
app/auth/mutations/forgot-password.test.ts
Normal 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()
|
||||
})
|
||||
})
|
42
app/auth/mutations/forgot-password.ts
Normal file
42
app/auth/mutations/forgot-password.ts
Normal 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
|
||||
})
|
31
app/auth/mutations/login.ts
Normal file
31
app/auth/mutations/login.ts
Normal 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
|
||||
})
|
5
app/auth/mutations/logout.ts
Normal file
5
app/auth/mutations/logout.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { Ctx } from "blitz"
|
||||
|
||||
export default async function logout(_: any, ctx: Ctx) {
|
||||
return await ctx.session.$revoke()
|
||||
}
|
83
app/auth/mutations/reset-password.test.ts
Normal file
83
app/auth/mutations/reset-password.test.ts
Normal 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
|
||||
)
|
||||
})
|
||||
})
|
48
app/auth/mutations/reset-password.ts
Normal file
48
app/auth/mutations/reset-password.ts
Normal 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
|
||||
})
|
18
app/auth/mutations/signup.ts
Normal file
18
app/auth/mutations/signup.ts
Normal 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
|
||||
})
|
52
app/auth/pages/forgot-password.tsx
Normal file
52
app/auth/pages/forgot-password.tsx
Normal 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
26
app/auth/pages/login.tsx
Normal 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
|
65
app/auth/pages/reset-password.tsx
Normal file
65
app/auth/pages/reset-password.tsx
Normal 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
19
app/auth/pages/signup.tsx
Normal 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
33
app/auth/validations.ts
Normal 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,
|
||||
})
|
84
app/core/components/form.tsx
Normal file
84
app/core/components/form.tsx
Normal 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
|
58
app/core/components/labeled-text-field.tsx
Normal file
58
app/core/components/labeled-text-field.tsx
Normal 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
|
11
app/core/hooks/use-current-customer.ts
Normal file
11
app/core/hooks/use-current-customer.ts
Normal 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),
|
||||
}
|
||||
}
|
15
app/core/hooks/use-customer-phone-number.ts
Normal file
15
app/core/hooks/use-customer-phone-number.ts
Normal 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
|
||||
}
|
24
app/core/hooks/use-require-onboarding.ts
Normal file
24
app/core/hooks/use-require-onboarding.ts
Normal 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())
|
||||
}
|
||||
}
|
22
app/core/layouts/base-layout.tsx
Normal file
22
app/core/layouts/base-layout.tsx
Normal 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
|
@ -1,26 +1,23 @@
|
||||
import type { ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import type { ReactNode } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/router"
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
|
||||
import {
|
||||
faPhoneAlt as fasPhone,
|
||||
faTh as fasTh,
|
||||
faComments as fasComments,
|
||||
faCog as fasCog,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
} from "@fortawesome/pro-solid-svg-icons"
|
||||
import {
|
||||
faPhoneAlt as farPhone,
|
||||
faTh as farTh,
|
||||
faComments as farComments,
|
||||
faCog as farCog,
|
||||
} from "@fortawesome/pro-regular-svg-icons";
|
||||
} from "@fortawesome/pro-regular-svg-icons"
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer
|
||||
className="grid grid-cols-4"
|
||||
style={{ flex: "0 0 50px" }}
|
||||
>
|
||||
<footer className="grid grid-cols-4" style={{ flex: "0 0 50px" }}>
|
||||
<NavLink
|
||||
label="Calls"
|
||||
path="/calls"
|
||||
@ -54,22 +51,22 @@ export default function Footer() {
|
||||
}}
|
||||
/>
|
||||
</footer>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
type NavLinkProps = {
|
||||
path: string;
|
||||
label: string;
|
||||
path: string
|
||||
label: string
|
||||
icons: {
|
||||
active: ReactNode;
|
||||
inactive: ReactNode;
|
||||
};
|
||||
active: ReactNode
|
||||
inactive: ReactNode
|
||||
}
|
||||
}
|
||||
|
||||
function NavLink({ path, label, icons }: NavLinkProps) {
|
||||
const router = useRouter();
|
||||
const isActiveRoute = router.pathname.startsWith(path);
|
||||
const icon = isActiveRoute ? icons.active : icons.inactive;
|
||||
const router = useRouter()
|
||||
const isActiveRoute = router.pathname.startsWith(path)
|
||||
const icon = isActiveRoute ? icons.active : icons.inactive
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-around h-full">
|
||||
@ -80,5 +77,5 @@ function NavLink({ path, label, icons }: NavLinkProps) {
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
@ -1,24 +1,26 @@
|
||||
import type { ErrorInfo, FunctionComponent } from "react";
|
||||
import { Component } from "react";
|
||||
import Head from "next/head";
|
||||
import type { WithRouterProps } from "next/dist/client/with-router";
|
||||
import { withRouter } from "next/router";
|
||||
import type { ErrorInfo, FunctionComponent } from "react"
|
||||
import { Component } from "react"
|
||||
import Head from "next/head"
|
||||
import type { WithRouterProps } from "next/dist/client/with-router"
|
||||
import { withRouter } from "next/router"
|
||||
|
||||
import appLogger from "../../../lib/logger";
|
||||
import appLogger from "../../../../integrations/logger"
|
||||
|
||||
import Footer from "./footer";
|
||||
import Footer from "./footer"
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
pageTitle?: string;
|
||||
};
|
||||
title: string
|
||||
pageTitle?: string
|
||||
hideFooter?: true
|
||||
}
|
||||
|
||||
const logger = appLogger.child({ module: "Layout" });
|
||||
const logger = appLogger.child({ module: "Layout" })
|
||||
|
||||
const Layout: FunctionComponent<Props> = ({
|
||||
children,
|
||||
title,
|
||||
pageTitle = title,
|
||||
hideFooter = false,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
@ -35,37 +37,37 @@ const Layout: FunctionComponent<Props> = ({
|
||||
<ErrorBoundary>{children}</ErrorBoundary>
|
||||
</main>
|
||||
</div>
|
||||
<Footer />
|
||||
{!hideFooter ? <Footer /> : null}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
type ErrorBoundaryState =
|
||||
| {
|
||||
isError: false;
|
||||
isError: false
|
||||
}
|
||||
| {
|
||||
isError: true;
|
||||
errorMessage: string;
|
||||
};
|
||||
isError: true
|
||||
errorMessage: string
|
||||
}
|
||||
|
||||
const ErrorBoundary = withRouter(
|
||||
class ErrorBoundary extends Component<WithRouterProps, ErrorBoundaryState> {
|
||||
public readonly state = {
|
||||
isError: false,
|
||||
} as const;
|
||||
} as const
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
return {
|
||||
isError: true,
|
||||
errorMessage: error.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
logger.error(error, errorInfo.componentStack);
|
||||
logger.error(error, errorInfo.componentStack)
|
||||
}
|
||||
|
||||
public render() {
|
||||
@ -88,12 +90,12 @@ const ErrorBoundary = withRouter(
|
||||
?
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
return this.props.children
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
)
|
||||
|
||||
export default Layout;
|
||||
export default Layout
|
21
app/customers/queries/get-current-customer.ts
Normal file
21
app/customers/queries/get-current-customer.ts
Normal 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,
|
||||
},
|
||||
})
|
||||
}
|
131
app/messages/api/webhook/incoming-message.ts
Normal file
131
app/messages/api/webhook/incoming-message.ts
Normal 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
|
||||
}
|
||||
}
|
82
app/messages/components/conversation.tsx
Normal file
82
app/messages/components/conversation.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
34
app/messages/components/conversations-list.tsx
Normal file
34
app/messages/components/conversations-list.tsx
Normal 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>
|
||||
)
|
||||
}
|
94
app/messages/components/new-message-area.tsx
Normal file
94
app/messages/components/new-message-area.tsx
Normal 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)
|
||||
})
|
||||
}
|
19
app/messages/hooks/use-conversation.ts
Normal file
19
app/messages/hooks/use-conversation.ts
Normal 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]!
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
47
app/messages/mutations/send-message.ts
Normal file
47
app/messages/mutations/send-message.ts
Normal 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,
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
25
app/messages/pages/messages.tsx
Normal file
25
app/messages/pages/messages.tsx
Normal 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
|
39
app/messages/pages/messages/[recipient].tsx
Normal file
39
app/messages/pages/messages/[recipient].tsx
Normal 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
|
31
app/messages/queries/get-conversation.ts
Normal file
31
app/messages/queries/get-conversation.ts
Normal 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),
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
41
app/messages/queries/get-conversations.ts
Normal file
41
app/messages/queries/get-conversations.ts
Normal 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
|
||||
})
|
@ -1,71 +1,73 @@
|
||||
import type { FunctionComponent } from "react";
|
||||
import { CheckIcon } from "@heroicons/react/solid";
|
||||
import clsx from "clsx";
|
||||
import Link from "next/link";
|
||||
import type { FunctionComponent } from "react"
|
||||
import { CheckIcon } from "@heroicons/react/solid"
|
||||
import clsx from "clsx"
|
||||
import { Link, Routes, useRouter } from "blitz"
|
||||
|
||||
import useCustomerPhoneNumber from "../../core/hooks/use-customer-phone-number"
|
||||
|
||||
type StepLink = {
|
||||
href: string;
|
||||
label: string;
|
||||
href: string
|
||||
label: string
|
||||
}
|
||||
|
||||
type Props = {
|
||||
currentStep: 1 | 2 | 3;
|
||||
previous?: StepLink;
|
||||
next?: StepLink;
|
||||
};
|
||||
currentStep: 1 | 2 | 3
|
||||
previous?: StepLink
|
||||
next?: StepLink
|
||||
}
|
||||
|
||||
const steps = [
|
||||
"Welcome",
|
||||
"Twilio Credentials",
|
||||
"Pick a plan",
|
||||
] as const;
|
||||
const steps = ["Welcome", "Twilio Credentials", "Pick a plan"] as const
|
||||
|
||||
const OnboardingLayout: FunctionComponent<Props> = ({ children, currentStep, previous, next }) => {
|
||||
const router = useRouter()
|
||||
const customerPhoneNumber = useCustomerPhoneNumber()
|
||||
|
||||
if (customerPhoneNumber) {
|
||||
throw router.push(Routes.Messages())
|
||||
}
|
||||
|
||||
const OnboardingLayout: FunctionComponent<Props> = ({
|
||||
children,
|
||||
currentStep,
|
||||
previous,
|
||||
next,
|
||||
}) => {
|
||||
return (
|
||||
<div className="bg-gray-800 fixed z-10 inset-0 overflow-y-auto">
|
||||
<div className="min-h-screen text-center block p-0">
|
||||
{/* This element is to trick the browser into centering the modal contents. */}
|
||||
<span className="inline-block align-middle h-screen">
|
||||
​
|
||||
</span>
|
||||
<span className="inline-block align-middle h-screen">​</span>
|
||||
<div className="inline-flex flex-col bg-white rounded-lg text-left overflow-hidden shadow transform transition-all my-8 align-middle max-w-2xl w-[90%] pb-6">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900 px-6 py-5 border-b border-gray-100">{steps[currentStep - 1]}</h3>
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900 px-6 py-5 border-b border-gray-100">
|
||||
{steps[currentStep - 1]}
|
||||
</h3>
|
||||
|
||||
<section className="px-6 pt-6 pb-12">{children}</section>
|
||||
|
||||
<nav className="grid grid-cols-1 gap-y-3 mx-auto">
|
||||
{
|
||||
next ? (
|
||||
<Link href={next.href}>
|
||||
<a className="max-w-[240px] mx-auto w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-primary-600 text-base font-medium text-white hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:text-sm">
|
||||
{next.label}
|
||||
</a>
|
||||
</Link>
|
||||
) : null
|
||||
}
|
||||
{next ? (
|
||||
<Link href={next.href}>
|
||||
<a className="max-w-[240px] mx-auto w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-primary-600 text-base font-medium text-white hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:text-sm">
|
||||
{next.label}
|
||||
</a>
|
||||
</Link>
|
||||
) : null}
|
||||
|
||||
{
|
||||
previous ? (
|
||||
<Link href={previous.href}>
|
||||
<a className="max-w-[240px] mx-auto w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:text-sm">
|
||||
{previous.label}
|
||||
</a>
|
||||
</Link>
|
||||
) : null
|
||||
}
|
||||
{previous ? (
|
||||
<Link href={previous.href}>
|
||||
<a className="max-w-[240px] mx-auto w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:text-sm">
|
||||
{previous.label}
|
||||
</a>
|
||||
</Link>
|
||||
) : null}
|
||||
|
||||
<ol className="flex items-center">
|
||||
{steps.map((step, stepIdx) => {
|
||||
const isComplete = currentStep > stepIdx + 1;
|
||||
const isCurrent = stepIdx + 1 === currentStep;
|
||||
const isComplete = currentStep > stepIdx + 1
|
||||
const isCurrent = stepIdx + 1 === currentStep
|
||||
|
||||
return (
|
||||
<li key={step} className={clsx(stepIdx !== steps.length - 1 ? "pr-20 sm:pr-32" : "", "relative")}>
|
||||
<li
|
||||
key={step}
|
||||
className={clsx(
|
||||
stepIdx !== steps.length - 1 ? "pr-20 sm:pr-32" : "",
|
||||
"relative"
|
||||
)}
|
||||
>
|
||||
{isComplete ? (
|
||||
<>
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
@ -105,7 +107,7 @@ const OnboardingLayout: FunctionComponent<Props> = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default OnboardingLayout;
|
||||
export default OnboardingLayout
|
40
app/onboarding/mutations/set-phone-number.ts
Normal file
40
app/onboarding/mutations/set-phone-number.ts
Normal 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}` }),
|
||||
])
|
||||
}
|
||||
)
|
26
app/onboarding/mutations/set-twilio-api-fields.ts
Normal file
26
app/onboarding/mutations/set-twilio-api-fields.ts
Normal 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,
|
||||
},
|
||||
})
|
||||
}
|
||||
)
|
23
app/onboarding/pages/welcome/step-one.tsx
Normal file
23
app/onboarding/pages/welcome/step-one.tsx
Normal 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, let’s set up your virtual phone!</span>
|
||||
</div>
|
||||
</OnboardingLayout>
|
||||
)
|
||||
}
|
||||
|
||||
StepOne.authenticate = true
|
||||
|
||||
export default StepOne
|
124
app/onboarding/pages/welcome/step-three.tsx
Normal file
124
app/onboarding/pages/welcome/step-three.tsx
Normal 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
|
@ -1,47 +1,49 @@
|
||||
import type { InferGetServerSidePropsType, NextPage } from "next";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import axios from "axios";
|
||||
import type { BlitzPage } from "blitz"
|
||||
import { Routes, useMutation, useRouter } from "blitz"
|
||||
import clsx from "clsx"
|
||||
import { useEffect } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
|
||||
import { withPageAuthRequired } from "../../../lib/session-helpers";
|
||||
import OnboardingLayout from "../../components/welcome/onboarding-layout";
|
||||
import clsx from "clsx";
|
||||
import { findCustomer } from "../../database/customer";
|
||||
|
||||
type Props = InferGetServerSidePropsType<typeof getServerSideProps>;
|
||||
import OnboardingLayout from "../../components/onboarding-layout"
|
||||
import useCurrentCustomer from "../../../core/hooks/use-current-customer"
|
||||
import setTwilioApiFields from "../../mutations/set-twilio-api-fields"
|
||||
|
||||
type Form = {
|
||||
twilioAccountSid: string;
|
||||
twilioAuthToken: string;
|
||||
twilioAccountSid: string
|
||||
twilioAuthToken: string
|
||||
}
|
||||
|
||||
const StepTwo: NextPage<Props> = ({ accountSid, authToken }) => {
|
||||
const StepTwo: BlitzPage = () => {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
formState: { isSubmitting },
|
||||
} = useForm<Form>();
|
||||
const router = useRouter();
|
||||
} = useForm<Form>()
|
||||
const router = useRouter()
|
||||
const { customer } = useCurrentCustomer()
|
||||
const [setTwilioApiFieldsMutation] = useMutation(setTwilioApiFields)
|
||||
|
||||
const initialAuthToken = customer?.authToken ?? ""
|
||||
const initialAccountSid = customer?.accountSid ?? ""
|
||||
const hasTwilioCredentials = initialAccountSid.length > 0 && initialAuthToken.length > 0
|
||||
useEffect(() => {
|
||||
setValue("twilioAuthToken", authToken);
|
||||
setValue("twilioAccountSid", accountSid);
|
||||
});
|
||||
setValue("twilioAuthToken", initialAuthToken)
|
||||
setValue("twilioAccountSid", initialAccountSid)
|
||||
}, [initialAuthToken, initialAccountSid])
|
||||
|
||||
const onSubmit = handleSubmit(async ({ twilioAccountSid, twilioAuthToken }) => {
|
||||
if (isSubmitting) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
await axios.post("/api/user/update-user", {
|
||||
await setTwilioApiFieldsMutation({
|
||||
twilioAccountSid,
|
||||
twilioAuthToken,
|
||||
}, { withCredentials: true });
|
||||
await router.push("/welcome/step-three");
|
||||
});
|
||||
const hasTwilioCredentials = accountSid.length > 0 && authToken.length > 0;
|
||||
})
|
||||
|
||||
await router.push(Routes.StepThree())
|
||||
})
|
||||
|
||||
return (
|
||||
<OnboardingLayout
|
||||
@ -52,7 +54,10 @@ const StepTwo: NextPage<Props> = ({ accountSid, authToken }) => {
|
||||
<div className="flex flex-col space-y-4 items-center">
|
||||
<form onSubmit={onSubmit} className="flex flex-col gap-6">
|
||||
<div className="w-full">
|
||||
<label htmlFor="twilioAccountSid" className="block text-sm font-medium text-gray-700">
|
||||
<label
|
||||
htmlFor="twilioAccountSid"
|
||||
className="block text-sm font-medium text-gray-700"
|
||||
>
|
||||
Twilio Account SID
|
||||
</label>
|
||||
<input
|
||||
@ -63,7 +68,10 @@ const StepTwo: NextPage<Props> = ({ accountSid, authToken }) => {
|
||||
/>
|
||||
</div>
|
||||
<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
|
||||
</label>
|
||||
<input
|
||||
@ -79,7 +87,7 @@ const StepTwo: NextPage<Props> = ({ accountSid, authToken }) => {
|
||||
className={clsx(
|
||||
"max-w-[240px] mx-auto w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 text-base font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:text-sm",
|
||||
!isSubmitting && "bg-primary-600 hover:bg-primary-700",
|
||||
isSubmitting && "bg-primary-400 cursor-not-allowed",
|
||||
isSubmitting && "bg-primary-400 cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
Save
|
||||
@ -87,18 +95,9 @@ const StepTwo: NextPage<Props> = ({ accountSid, authToken }) => {
|
||||
</form>
|
||||
</div>
|
||||
</OnboardingLayout>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export const getServerSideProps = withPageAuthRequired(async (context, user) => {
|
||||
const customer = await findCustomer(user.id);
|
||||
StepTwo.authenticate = true
|
||||
|
||||
return {
|
||||
props: {
|
||||
accountSid: customer.accountSid ?? "",
|
||||
authToken: customer.authToken ?? "",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export default StepTwo;
|
||||
export default StepTwo
|
19
app/pages/404.tsx
Normal file
19
app/pages/404.tsx
Normal 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
49
app/pages/_app.tsx
Normal 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
23
app/pages/_document.tsx
Normal 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
39
app/pages/index.test.tsx
Normal 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
273
app/pages/index.tsx
Normal 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
|
3
app/phone-calls/api/webhook/incoming-call.ts
Normal file
3
app/phone-calls/api/webhook/incoming-call.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next"
|
||||
|
||||
export default async function incomingCallHandler(req: NextApiRequest, res: NextApiResponse) {}
|
3
app/phone-calls/api/webhook/outgoing-call.ts
Normal file
3
app/phone-calls/api/webhook/outgoing-call.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next"
|
||||
|
||||
export default async function outgoingCallHandler(req: NextApiRequest, res: NextApiResponse) {}
|
24
app/phone-calls/components/phone-calls-list.tsx
Normal file
24
app/phone-calls/components/phone-calls-list.tsx
Normal 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>
|
||||
)
|
||||
}
|
15
app/phone-calls/hooks/use-phone-calls.ts
Normal file
15
app/phone-calls/hooks/use-phone-calls.ts
Normal 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
|
||||
}
|
25
app/phone-calls/pages/calls.tsx
Normal file
25
app/phone-calls/pages/calls.tsx
Normal 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
|
32
app/phone-calls/queries/get-phone-calls.ts
Normal file
32
app/phone-calls/queries/get-phone-calls.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
)
|
@ -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,
|
||||
},
|
||||
})
|
||||
})
|
19
app/phone-numbers/queries/get-customer-phone-number.ts
Normal file
19
app/phone-numbers/queries/get-customer-phone-number.ts
Normal 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
4
babel.config.js
Normal file
@ -0,0 +1,4 @@
|
||||
module.exports = {
|
||||
presets: ["blitz/babel"],
|
||||
plugins: [],
|
||||
}
|
36
blitz.config.ts
Normal file
36
blitz.config.ts
Normal 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
37
db/_encryption.ts
Normal 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
7
db/index.ts
Normal 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()
|
57
db/migrations/20210726100838_init/migration.sql
Normal file
57
db/migrations/20210726100838_init/migration.sql
Normal 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;
|
137
db/migrations/20210727115631_import_models/migration.sql
Normal file
137
db/migrations/20210727115631_import_models/migration.sql
Normal 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;
|
12
db/migrations/20210727125716_user_role_enum/migration.sql
Normal file
12
db/migrations/20210727125716_user_role_enum/migration.sql
Normal 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';
|
@ -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;
|
3
db/migrations/migration_lock.toml
Normal file
3
db/migrations/migration_lock.toml
Normal 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
154
db/schema.prisma
Normal 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
16
db/seeds.ts
Normal 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
3
global.d.ts
vendored
Normal 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" />
|
@ -1,4 +1,4 @@
|
||||
import pino from "pino";
|
||||
import pino from "pino"
|
||||
|
||||
const appLogger = pino({
|
||||
level: "debug",
|
||||
@ -7,6 +7,6 @@ const appLogger = pino({
|
||||
revision: process.env.VERCEL_GITHUB_COMMIT_SHA,
|
||||
},
|
||||
prettyPrint: true,
|
||||
});
|
||||
})
|
||||
|
||||
export default appLogger;
|
||||
export default appLogger
|
@ -1,15 +1,3 @@
|
||||
module.exports = {
|
||||
collectCoverageFrom: [
|
||||
"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",
|
||||
};
|
||||
preset: "blitz",
|
||||
}
|
||||
|
104
jest/helpers.ts
104
jest/helpers.ts
@ -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("");
|
||||
}
|
@ -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
|
||||
}
|
@ -1 +0,0 @@
|
||||
export * from "@testing-library/react";
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
@ -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
Loading…
Reference in New Issue
Block a user