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/*
|
# dependencies
|
||||||
node_modules/*
|
node_modules
|
||||||
.idea/*
|
.yarn/cache
|
||||||
build/*
|
.yarn/unplugged
|
||||||
.env
|
.yarn/build-state.yml
|
||||||
coverage/
|
.pnp.*
|
||||||
|
.npm
|
||||||
|
web_modules/
|
||||||
|
|
||||||
|
# blitz
|
||||||
|
/.blitz/
|
||||||
|
/.next/
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite-journal
|
||||||
|
.now
|
||||||
|
.blitz**
|
||||||
|
blitz-log.log
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
.envrc
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
.coverage
|
||||||
|
*.lcov
|
||||||
|
.nyc_output
|
||||||
|
lib-cov
|
||||||
|
|
||||||
|
# Caches
|
||||||
|
*.tsbuildinfo
|
||||||
|
.eslintcache
|
||||||
|
.node_repl_history
|
||||||
|
.yarn-integrity
|
||||||
|
|
||||||
|
# Serverless directories
|
||||||
|
.serverless/
|
||||||
|
|
||||||
|
# Stores VSCode versions used for testing VSCode extensions
|
||||||
|
.vscode-test
|
||||||
|
1
.husky/.gitignore
vendored
Normal file
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 getConfig from "next/config"
|
||||||
import axios from "axios";
|
import axios from "axios"
|
||||||
|
|
||||||
const { serverRuntimeConfig } = getConfig();
|
const { serverRuntimeConfig } = getConfig()
|
||||||
|
|
||||||
export async function addSubscriber(email: string) {
|
export async function addSubscriber(email: string) {
|
||||||
const { apiKey, audienceId } = serverRuntimeConfig.mailChimp;
|
const { apiKey, audienceId } = serverRuntimeConfig.mailChimp
|
||||||
const region = apiKey.split("-")[1];
|
const region = apiKey.split("-")[1]
|
||||||
const url = `https://${region}.api.mailchimp.com/3.0/lists/${audienceId}/members`;
|
const url = `https://${region}.api.mailchimp.com/3.0/lists/${audienceId}/members`
|
||||||
const data = {
|
const data = {
|
||||||
email_address: email,
|
email_address: email,
|
||||||
status: "subscribed",
|
status: "subscribed",
|
||||||
};
|
}
|
||||||
const base64ApiKey = Buffer.from(`any:${apiKey}`).toString("base64");
|
const base64ApiKey = Buffer.from(`any:${apiKey}`).toString("base64")
|
||||||
const headers = {
|
const headers = {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Authorization: `Basic ${base64ApiKey}`,
|
Authorization: `Basic ${base64ApiKey}`,
|
||||||
};
|
}
|
||||||
|
|
||||||
return axios.post(url, data, { headers });
|
return axios.post(url, data, { headers })
|
||||||
}
|
}
|
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 type { ReactNode } from "react"
|
||||||
import Link from "next/link";
|
import Link from "next/link"
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router"
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
|
||||||
import {
|
import {
|
||||||
faPhoneAlt as fasPhone,
|
faPhoneAlt as fasPhone,
|
||||||
faTh as fasTh,
|
faTh as fasTh,
|
||||||
faComments as fasComments,
|
faComments as fasComments,
|
||||||
faCog as fasCog,
|
faCog as fasCog,
|
||||||
} from "@fortawesome/pro-solid-svg-icons";
|
} from "@fortawesome/pro-solid-svg-icons"
|
||||||
import {
|
import {
|
||||||
faPhoneAlt as farPhone,
|
faPhoneAlt as farPhone,
|
||||||
faTh as farTh,
|
faTh as farTh,
|
||||||
faComments as farComments,
|
faComments as farComments,
|
||||||
faCog as farCog,
|
faCog as farCog,
|
||||||
} from "@fortawesome/pro-regular-svg-icons";
|
} from "@fortawesome/pro-regular-svg-icons"
|
||||||
|
|
||||||
export default function Footer() {
|
export default function Footer() {
|
||||||
return (
|
return (
|
||||||
<footer
|
<footer className="grid grid-cols-4" style={{ flex: "0 0 50px" }}>
|
||||||
className="grid grid-cols-4"
|
|
||||||
style={{ flex: "0 0 50px" }}
|
|
||||||
>
|
|
||||||
<NavLink
|
<NavLink
|
||||||
label="Calls"
|
label="Calls"
|
||||||
path="/calls"
|
path="/calls"
|
||||||
@ -54,22 +51,22 @@ export default function Footer() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</footer>
|
</footer>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
type NavLinkProps = {
|
type NavLinkProps = {
|
||||||
path: string;
|
path: string
|
||||||
label: string;
|
label: string
|
||||||
icons: {
|
icons: {
|
||||||
active: ReactNode;
|
active: ReactNode
|
||||||
inactive: ReactNode;
|
inactive: ReactNode
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function NavLink({ path, label, icons }: NavLinkProps) {
|
function NavLink({ path, label, icons }: NavLinkProps) {
|
||||||
const router = useRouter();
|
const router = useRouter()
|
||||||
const isActiveRoute = router.pathname.startsWith(path);
|
const isActiveRoute = router.pathname.startsWith(path)
|
||||||
const icon = isActiveRoute ? icons.active : icons.inactive;
|
const icon = isActiveRoute ? icons.active : icons.inactive
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-around h-full">
|
<div className="flex flex-col items-center justify-around h-full">
|
||||||
@ -80,5 +77,5 @@ function NavLink({ path, label, icons }: NavLinkProps) {
|
|||||||
</a>
|
</a>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
@ -1,24 +1,26 @@
|
|||||||
import type { ErrorInfo, FunctionComponent } from "react";
|
import type { ErrorInfo, FunctionComponent } from "react"
|
||||||
import { Component } from "react";
|
import { Component } from "react"
|
||||||
import Head from "next/head";
|
import Head from "next/head"
|
||||||
import type { WithRouterProps } from "next/dist/client/with-router";
|
import type { WithRouterProps } from "next/dist/client/with-router"
|
||||||
import { withRouter } from "next/router";
|
import { withRouter } from "next/router"
|
||||||
|
|
||||||
import appLogger from "../../../lib/logger";
|
import appLogger from "../../../../integrations/logger"
|
||||||
|
|
||||||
import Footer from "./footer";
|
import Footer from "./footer"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
title: string;
|
title: string
|
||||||
pageTitle?: string;
|
pageTitle?: string
|
||||||
};
|
hideFooter?: true
|
||||||
|
}
|
||||||
|
|
||||||
const logger = appLogger.child({ module: "Layout" });
|
const logger = appLogger.child({ module: "Layout" })
|
||||||
|
|
||||||
const Layout: FunctionComponent<Props> = ({
|
const Layout: FunctionComponent<Props> = ({
|
||||||
children,
|
children,
|
||||||
title,
|
title,
|
||||||
pageTitle = title,
|
pageTitle = title,
|
||||||
|
hideFooter = false,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -35,37 +37,37 @@ const Layout: FunctionComponent<Props> = ({
|
|||||||
<ErrorBoundary>{children}</ErrorBoundary>
|
<ErrorBoundary>{children}</ErrorBoundary>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<Footer />
|
{!hideFooter ? <Footer /> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
type ErrorBoundaryState =
|
type ErrorBoundaryState =
|
||||||
| {
|
| {
|
||||||
isError: false;
|
isError: false
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
isError: true;
|
isError: true
|
||||||
errorMessage: string;
|
errorMessage: string
|
||||||
};
|
}
|
||||||
|
|
||||||
const ErrorBoundary = withRouter(
|
const ErrorBoundary = withRouter(
|
||||||
class ErrorBoundary extends Component<WithRouterProps, ErrorBoundaryState> {
|
class ErrorBoundary extends Component<WithRouterProps, ErrorBoundaryState> {
|
||||||
public readonly state = {
|
public readonly state = {
|
||||||
isError: false,
|
isError: false,
|
||||||
} as const;
|
} as const
|
||||||
|
|
||||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||||
return {
|
return {
|
||||||
isError: true,
|
isError: true,
|
||||||
errorMessage: error.message,
|
errorMessage: error.message,
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||||
logger.error(error, errorInfo.componentStack);
|
logger.error(error, errorInfo.componentStack)
|
||||||
}
|
}
|
||||||
|
|
||||||
public render() {
|
public render() {
|
||||||
@ -88,12 +90,12 @@ const ErrorBoundary = withRouter(
|
|||||||
?
|
?
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.props.children;
|
return this.props.children
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
);
|
)
|
||||||
|
|
||||||
export default Layout;
|
export default Layout
|
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 type { FunctionComponent } from "react"
|
||||||
import { CheckIcon } from "@heroicons/react/solid";
|
import { CheckIcon } from "@heroicons/react/solid"
|
||||||
import clsx from "clsx";
|
import clsx from "clsx"
|
||||||
import Link from "next/link";
|
import { Link, Routes, useRouter } from "blitz"
|
||||||
|
|
||||||
|
import useCustomerPhoneNumber from "../../core/hooks/use-customer-phone-number"
|
||||||
|
|
||||||
type StepLink = {
|
type StepLink = {
|
||||||
href: string;
|
href: string
|
||||||
label: string;
|
label: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
currentStep: 1 | 2 | 3;
|
currentStep: 1 | 2 | 3
|
||||||
previous?: StepLink;
|
previous?: StepLink
|
||||||
next?: StepLink;
|
next?: StepLink
|
||||||
};
|
}
|
||||||
|
|
||||||
const steps = [
|
const steps = ["Welcome", "Twilio Credentials", "Pick a plan"] as const
|
||||||
"Welcome",
|
|
||||||
"Twilio Credentials",
|
const OnboardingLayout: FunctionComponent<Props> = ({ children, currentStep, previous, next }) => {
|
||||||
"Pick a plan",
|
const router = useRouter()
|
||||||
] as const;
|
const customerPhoneNumber = useCustomerPhoneNumber()
|
||||||
|
|
||||||
|
if (customerPhoneNumber) {
|
||||||
|
throw router.push(Routes.Messages())
|
||||||
|
}
|
||||||
|
|
||||||
const OnboardingLayout: FunctionComponent<Props> = ({
|
|
||||||
children,
|
|
||||||
currentStep,
|
|
||||||
previous,
|
|
||||||
next,
|
|
||||||
}) => {
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-gray-800 fixed z-10 inset-0 overflow-y-auto">
|
<div className="bg-gray-800 fixed z-10 inset-0 overflow-y-auto">
|
||||||
<div className="min-h-screen text-center block p-0">
|
<div className="min-h-screen text-center block p-0">
|
||||||
{/* This element is to trick the browser into centering the modal contents. */}
|
{/* This element is to trick the browser into centering the modal contents. */}
|
||||||
<span className="inline-block align-middle h-screen">
|
<span className="inline-block align-middle h-screen">​</span>
|
||||||
​
|
|
||||||
</span>
|
|
||||||
<div className="inline-flex flex-col bg-white rounded-lg text-left overflow-hidden shadow transform transition-all my-8 align-middle max-w-2xl w-[90%] pb-6">
|
<div className="inline-flex flex-col bg-white rounded-lg text-left overflow-hidden shadow transform transition-all my-8 align-middle max-w-2xl w-[90%] pb-6">
|
||||||
<h3 className="text-lg leading-6 font-medium text-gray-900 px-6 py-5 border-b border-gray-100">{steps[currentStep - 1]}</h3>
|
<h3 className="text-lg leading-6 font-medium text-gray-900 px-6 py-5 border-b border-gray-100">
|
||||||
|
{steps[currentStep - 1]}
|
||||||
|
</h3>
|
||||||
|
|
||||||
<section className="px-6 pt-6 pb-12">{children}</section>
|
<section className="px-6 pt-6 pb-12">{children}</section>
|
||||||
|
|
||||||
<nav className="grid grid-cols-1 gap-y-3 mx-auto">
|
<nav className="grid grid-cols-1 gap-y-3 mx-auto">
|
||||||
{
|
{next ? (
|
||||||
next ? (
|
<Link href={next.href}>
|
||||||
<Link href={next.href}>
|
<a className="max-w-[240px] mx-auto w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-primary-600 text-base font-medium text-white hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:text-sm">
|
||||||
<a className="max-w-[240px] mx-auto w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-primary-600 text-base font-medium text-white hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:text-sm">
|
{next.label}
|
||||||
{next.label}
|
</a>
|
||||||
</a>
|
</Link>
|
||||||
</Link>
|
) : null}
|
||||||
) : null
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
{previous ? (
|
||||||
previous ? (
|
<Link href={previous.href}>
|
||||||
<Link href={previous.href}>
|
<a className="max-w-[240px] mx-auto w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:text-sm">
|
||||||
<a className="max-w-[240px] mx-auto w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:text-sm">
|
{previous.label}
|
||||||
{previous.label}
|
</a>
|
||||||
</a>
|
</Link>
|
||||||
</Link>
|
) : null}
|
||||||
) : null
|
|
||||||
}
|
|
||||||
|
|
||||||
<ol className="flex items-center">
|
<ol className="flex items-center">
|
||||||
{steps.map((step, stepIdx) => {
|
{steps.map((step, stepIdx) => {
|
||||||
const isComplete = currentStep > stepIdx + 1;
|
const isComplete = currentStep > stepIdx + 1
|
||||||
const isCurrent = stepIdx + 1 === currentStep;
|
const isCurrent = stepIdx + 1 === currentStep
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li key={step} className={clsx(stepIdx !== steps.length - 1 ? "pr-20 sm:pr-32" : "", "relative")}>
|
<li
|
||||||
|
key={step}
|
||||||
|
className={clsx(
|
||||||
|
stepIdx !== steps.length - 1 ? "pr-20 sm:pr-32" : "",
|
||||||
|
"relative"
|
||||||
|
)}
|
||||||
|
>
|
||||||
{isComplete ? (
|
{isComplete ? (
|
||||||
<>
|
<>
|
||||||
<div className="absolute inset-0 flex items-center">
|
<div className="absolute inset-0 flex items-center">
|
||||||
@ -105,7 +107,7 @@ const OnboardingLayout: FunctionComponent<Props> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
export default OnboardingLayout;
|
export default OnboardingLayout
|
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 type { BlitzPage } from "blitz"
|
||||||
import { useRouter } from "next/router";
|
import { Routes, useMutation, useRouter } from "blitz"
|
||||||
import { useEffect } from "react";
|
import clsx from "clsx"
|
||||||
import { useForm } from "react-hook-form";
|
import { useEffect } from "react"
|
||||||
import axios from "axios";
|
import { useForm } from "react-hook-form"
|
||||||
|
|
||||||
import { withPageAuthRequired } from "../../../lib/session-helpers";
|
import OnboardingLayout from "../../components/onboarding-layout"
|
||||||
import OnboardingLayout from "../../components/welcome/onboarding-layout";
|
import useCurrentCustomer from "../../../core/hooks/use-current-customer"
|
||||||
import clsx from "clsx";
|
import setTwilioApiFields from "../../mutations/set-twilio-api-fields"
|
||||||
import { findCustomer } from "../../database/customer";
|
|
||||||
|
|
||||||
type Props = InferGetServerSidePropsType<typeof getServerSideProps>;
|
|
||||||
|
|
||||||
type Form = {
|
type Form = {
|
||||||
twilioAccountSid: string;
|
twilioAccountSid: string
|
||||||
twilioAuthToken: string;
|
twilioAuthToken: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const StepTwo: NextPage<Props> = ({ accountSid, authToken }) => {
|
const StepTwo: BlitzPage = () => {
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
setValue,
|
setValue,
|
||||||
formState: { isSubmitting },
|
formState: { isSubmitting },
|
||||||
} = useForm<Form>();
|
} = useForm<Form>()
|
||||||
const router = useRouter();
|
const router = useRouter()
|
||||||
|
const { customer } = useCurrentCustomer()
|
||||||
|
const [setTwilioApiFieldsMutation] = useMutation(setTwilioApiFields)
|
||||||
|
|
||||||
|
const initialAuthToken = customer?.authToken ?? ""
|
||||||
|
const initialAccountSid = customer?.accountSid ?? ""
|
||||||
|
const hasTwilioCredentials = initialAccountSid.length > 0 && initialAuthToken.length > 0
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setValue("twilioAuthToken", authToken);
|
setValue("twilioAuthToken", initialAuthToken)
|
||||||
setValue("twilioAccountSid", accountSid);
|
setValue("twilioAccountSid", initialAccountSid)
|
||||||
});
|
}, [initialAuthToken, initialAccountSid])
|
||||||
|
|
||||||
const onSubmit = handleSubmit(async ({ twilioAccountSid, twilioAuthToken }) => {
|
const onSubmit = handleSubmit(async ({ twilioAccountSid, twilioAuthToken }) => {
|
||||||
if (isSubmitting) {
|
if (isSubmitting) {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await axios.post("/api/user/update-user", {
|
await setTwilioApiFieldsMutation({
|
||||||
twilioAccountSid,
|
twilioAccountSid,
|
||||||
twilioAuthToken,
|
twilioAuthToken,
|
||||||
}, { withCredentials: true });
|
})
|
||||||
await router.push("/welcome/step-three");
|
|
||||||
});
|
await router.push(Routes.StepThree())
|
||||||
const hasTwilioCredentials = accountSid.length > 0 && authToken.length > 0;
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OnboardingLayout
|
<OnboardingLayout
|
||||||
@ -52,7 +54,10 @@ const StepTwo: NextPage<Props> = ({ accountSid, authToken }) => {
|
|||||||
<div className="flex flex-col space-y-4 items-center">
|
<div className="flex flex-col space-y-4 items-center">
|
||||||
<form onSubmit={onSubmit} className="flex flex-col gap-6">
|
<form onSubmit={onSubmit} className="flex flex-col gap-6">
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<label htmlFor="twilioAccountSid" className="block text-sm font-medium text-gray-700">
|
<label
|
||||||
|
htmlFor="twilioAccountSid"
|
||||||
|
className="block text-sm font-medium text-gray-700"
|
||||||
|
>
|
||||||
Twilio Account SID
|
Twilio Account SID
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@ -63,7 +68,10 @@ const StepTwo: NextPage<Props> = ({ accountSid, authToken }) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<label htmlFor="twilioAuthToken" className="block text-sm font-medium text-gray-700">
|
<label
|
||||||
|
htmlFor="twilioAuthToken"
|
||||||
|
className="block text-sm font-medium text-gray-700"
|
||||||
|
>
|
||||||
Twilio Auth Token
|
Twilio Auth Token
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@ -79,7 +87,7 @@ const StepTwo: NextPage<Props> = ({ accountSid, authToken }) => {
|
|||||||
className={clsx(
|
className={clsx(
|
||||||
"max-w-[240px] mx-auto w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 text-base font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:text-sm",
|
"max-w-[240px] mx-auto w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 text-base font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:text-sm",
|
||||||
!isSubmitting && "bg-primary-600 hover:bg-primary-700",
|
!isSubmitting && "bg-primary-600 hover:bg-primary-700",
|
||||||
isSubmitting && "bg-primary-400 cursor-not-allowed",
|
isSubmitting && "bg-primary-400 cursor-not-allowed"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
Save
|
Save
|
||||||
@ -87,18 +95,9 @@ const StepTwo: NextPage<Props> = ({ accountSid, authToken }) => {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</OnboardingLayout>
|
</OnboardingLayout>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
export const getServerSideProps = withPageAuthRequired(async (context, user) => {
|
StepTwo.authenticate = true
|
||||||
const customer = await findCustomer(user.id);
|
|
||||||
|
|
||||||
return {
|
export default StepTwo
|
||||||
props: {
|
|
||||||
accountSid: customer.accountSid ?? "",
|
|
||||||
authToken: customer.authToken ?? "",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
export default StepTwo;
|
|
19
app/pages/404.tsx
Normal file
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({
|
const appLogger = pino({
|
||||||
level: "debug",
|
level: "debug",
|
||||||
@ -7,6 +7,6 @@ const appLogger = pino({
|
|||||||
revision: process.env.VERCEL_GITHUB_COMMIT_SHA,
|
revision: process.env.VERCEL_GITHUB_COMMIT_SHA,
|
||||||
},
|
},
|
||||||
prettyPrint: true,
|
prettyPrint: true,
|
||||||
});
|
})
|
||||||
|
|
||||||
export default appLogger;
|
export default appLogger
|
@ -1,15 +1,3 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
collectCoverageFrom: [
|
preset: "blitz",
|
||||||
"src/**/*.{js,jsx,ts,tsx}",
|
}
|
||||||
"lib/**/*.{js,jsx,ts,tsx}",
|
|
||||||
],
|
|
||||||
transform: {
|
|
||||||
"^.+\\.(js|jsx|ts|tsx)$": "<rootDir>/node_modules/babel-jest",
|
|
||||||
},
|
|
||||||
transformIgnorePatterns: [
|
|
||||||
"/node_modules/",
|
|
||||||
"/.next/",
|
|
||||||
],
|
|
||||||
setupFilesAfterEnv: ["./jest/setup.ts"],
|
|
||||||
testEnvironment: "node",
|
|
||||||
};
|
|
||||||
|
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