diff --git a/.babelrc b/.babelrc
deleted file mode 100644
index 7fa337a..0000000
--- a/.babelrc
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "presets": [
- "next/babel"
- ],
- "plugins": [
- "superjson-next"
- ]
-}
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..ad8f0ba
--- /dev/null
+++ b/.editorconfig
@@ -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
diff --git a/.eslintrc b/.eslintrc
deleted file mode 100644
index 15b1ed9..0000000
--- a/.eslintrc
+++ /dev/null
@@ -1,3 +0,0 @@
-{
- "extends": "next"
-}
diff --git a/.eslintrc.js b/.eslintrc.js
new file mode 100644
index 0000000..55d8cd1
--- /dev/null
+++ b/.eslintrc.js
@@ -0,0 +1,3 @@
+module.exports = {
+ extends: ["blitz"],
+}
diff --git a/.gitignore b/.gitignore
index e6e4e5e..f6fda81 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,7 +1,53 @@
-.next/*
-node_modules/*
-.idea/*
-build/*
-.env
-coverage/
+# dependencies
+node_modules
+.yarn/cache
+.yarn/unplugged
+.yarn/build-state.yml
+.pnp.*
+.npm
+web_modules/
+# blitz
+/.blitz/
+/.next/
+*.sqlite
+*.sqlite-journal
+.now
+.blitz**
+blitz-log.log
+
+# misc
+.DS_Store
+
+# local env files
+.env.local
+.env.*.local
+.envrc
+
+# Logs
+logs
+*.log
+
+# Runtime data
+pids
+*.pid
+*.seed
+*.pid.lock
+
+# Testing
+.coverage
+*.lcov
+.nyc_output
+lib-cov
+
+# Caches
+*.tsbuildinfo
+.eslintcache
+.node_repl_history
+.yarn-integrity
+
+# Serverless directories
+.serverless/
+
+# Stores VSCode versions used for testing VSCode extensions
+.vscode-test
diff --git a/.husky/.gitignore b/.husky/.gitignore
new file mode 100644
index 0000000..31354ec
--- /dev/null
+++ b/.husky/.gitignore
@@ -0,0 +1 @@
+_
diff --git a/.husky/pre-commit b/.husky/pre-commit
new file mode 100755
index 0000000..dd4268e
--- /dev/null
+++ b/.husky/pre-commit
@@ -0,0 +1,5 @@
+#!/bin/sh
+. "$(dirname "$0")/_/husky.sh"
+
+npx lint-staged
+npx pretty-quick --staged
diff --git a/.husky/pre-push b/.husky/pre-push
new file mode 100755
index 0000000..4918980
--- /dev/null
+++ b/.husky/pre-push
@@ -0,0 +1,6 @@
+#!/bin/sh
+. "$(dirname "$0")/_/husky.sh"
+
+npx tsc
+npm run lint
+npm run test
diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000..b58b603
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,5 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Editor-based HTTP Client requests
+/httpRequests/
diff --git a/.idea/modules.xml b/.idea/modules.xml
new file mode 100644
index 0000000..ea94aae
--- /dev/null
+++ b/.idea/modules.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000..94a25f7
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/virtual-phone.blitz.iml b/.idea/virtual-phone.blitz.iml
new file mode 100644
index 0000000..ea68f0d
--- /dev/null
+++ b/.idea/virtual-phone.blitz.iml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.npmrc b/.npmrc
new file mode 100644
index 0000000..1b78f1c
--- /dev/null
+++ b/.npmrc
@@ -0,0 +1,2 @@
+save-exact=true
+legacy-peer-deps=true
diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 0000000..ad8c486
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1,7 @@
+.gitkeep
+.env*
+*.ico
+*.lock
+db/migrations
+.next
+.blitz
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index 58e00e9..0000000
--- a/.travis.yml
+++ /dev/null
@@ -1,11 +0,0 @@
-language: node_js
-
-node_js:
- - node
- - 'lts/*'
-
-cache: npm
-
-script:
- - npm run build
- - npm run test
diff --git a/.vscode/extensions.json b/.vscode/extensions.json
new file mode 100644
index 0000000..900a577
--- /dev/null
+++ b/.vscode/extensions.json
@@ -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": []
+}
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..8d19091
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,7 @@
+{
+ "editor.defaultFormatter": "esbenp.prettier-vscode",
+ "editor.formatOnSave": true,
+ "editor.codeActionsOnSave": {
+ "source.fixAll.eslint": true
+ }
+}
diff --git a/app/api/_types.ts b/app/api/_types.ts
new file mode 100644
index 0000000..45beb19
--- /dev/null
+++ b/app/api/_types.ts
@@ -0,0 +1,4 @@
+export type ApiError = {
+ statusCode: number
+ errorMessage: string
+}
diff --git a/app/api/ddd.ts b/app/api/ddd.ts
new file mode 100644
index 0000000..62e0842
--- /dev/null
+++ b/app/api/ddd.ts
@@ -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()
+}
diff --git a/src/pages/api/newsletter/_mailchimp.ts b/app/api/newsletter/_mailchimp.ts
similarity index 54%
rename from src/pages/api/newsletter/_mailchimp.ts
rename to app/api/newsletter/_mailchimp.ts
index c9ca6bb..1cdfd06 100644
--- a/src/pages/api/newsletter/_mailchimp.ts
+++ b/app/api/newsletter/_mailchimp.ts
@@ -1,21 +1,21 @@
-import getConfig from "next/config";
-import axios from "axios";
+import getConfig from "next/config"
+import axios from "axios"
-const { serverRuntimeConfig } = getConfig();
+const { serverRuntimeConfig } = getConfig()
export async function addSubscriber(email: string) {
- const { apiKey, audienceId } = serverRuntimeConfig.mailChimp;
- const region = apiKey.split("-")[1];
- const url = `https://${region}.api.mailchimp.com/3.0/lists/${audienceId}/members`;
+ const { apiKey, audienceId } = serverRuntimeConfig.mailChimp
+ const region = apiKey.split("-")[1]
+ const url = `https://${region}.api.mailchimp.com/3.0/lists/${audienceId}/members`
const data = {
email_address: email,
status: "subscribed",
- };
- const base64ApiKey = Buffer.from(`any:${apiKey}`).toString("base64");
+ }
+ const base64ApiKey = Buffer.from(`any:${apiKey}`).toString("base64")
const headers = {
"Content-Type": "application/json",
Authorization: `Basic ${base64ApiKey}`,
- };
+ }
- return axios.post(url, data, { headers });
+ return axios.post(url, data, { headers })
}
diff --git a/app/api/newsletter/subscribe.ts b/app/api/newsletter/subscribe.ts
new file mode 100644
index 0000000..d68ea12
--- /dev/null
+++ b/app/api/newsletter/subscribe.ts
@@ -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
+) {
+ 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()
+}
diff --git a/app/api/queue/fetch-calls.ts b/app/api/queue/fetch-calls.ts
new file mode 100644
index 0000000..8bd0286
--- /dev/null
+++ b/app/api/queue/fetch-calls.ts
@@ -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("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
diff --git a/app/api/queue/fetch-messages.ts b/app/api/queue/fetch-messages.ts
new file mode 100644
index 0000000..5af91ba
--- /dev/null
+++ b/app/api/queue/fetch-messages.ts
@@ -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("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
diff --git a/app/api/queue/insert-calls.ts b/app/api/queue/insert-calls.ts
new file mode 100644
index 0000000..f707b54
--- /dev/null
+++ b/app/api/queue/insert-calls.ts
@@ -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("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
+ }
+}
diff --git a/app/api/queue/insert-messages.ts b/app/api/queue/insert-messages.ts
new file mode 100644
index 0000000..bda5def
--- /dev/null
+++ b/app/api/queue/insert-messages.ts
@@ -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(
+ "api/queue/insert-messages",
+ async ({ messages, customerId }) => {
+ const customer = await db.customer.findFirst({ where: { id: customerId } })
+ const encryptionKey = customer!.encryptionKey
+
+ const sms = messages
+ .map>((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
+ }
+}
diff --git a/app/api/queue/send-message.ts b/app/api/queue/send-message.ts
new file mode 100644
index 0000000..78ef16f
--- /dev/null
+++ b/app/api/queue/send-message.ts
@@ -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(
+ "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
diff --git a/app/api/queue/set-twilio-webhooks.ts b/app/api/queue/set-twilio-webhooks.ts
new file mode 100644
index 0000000..d1968c2
--- /dev/null
+++ b/app/api/queue/set-twilio-webhooks.ts
@@ -0,0 +1,43 @@
+import { Queue } from "quirrel/blitz"
+import twilio from "twilio"
+
+import db from "../../../db"
+
+type Payload = {
+ customerId: string
+}
+
+const setTwilioWebhooks = Queue(
+ "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
diff --git a/app/auth/components/login-form.tsx b/app/auth/components/login-form.tsx
new file mode 100644
index 0000000..52339fe
--- /dev/null
+++ b/app/auth/components/login-form.tsx
@@ -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 (
+
+
Login
+
+
+
+
+ Or Sign Up
+
+
+ )
+}
+
+export default LoginForm
diff --git a/app/auth/components/signup-form.tsx b/app/auth/components/signup-form.tsx
new file mode 100644
index 0000000..16b1ece
--- /dev/null
+++ b/app/auth/components/signup-form.tsx
@@ -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 (
+
+
Create an Account
+
+
+
+ )
+}
+
+export default SignupForm
diff --git a/app/auth/mutations/change-password.ts b/app/auth/mutations/change-password.ts
new file mode 100644
index 0000000..4b24476
--- /dev/null
+++ b/app/auth/mutations/change-password.ts
@@ -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
+ }
+)
diff --git a/app/auth/mutations/forgot-password.test.ts b/app/auth/mutations/forgot-password.test.ts
new file mode 100644
index 0000000..b07d9af
--- /dev/null
+++ b/app/auth/mutations/forgot-password.test.ts
@@ -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
>
- );
+ )
}
- return this.props.children;
+ return this.props.children
}
- },
-);
+ }
+)
-export default Layout;
+export default Layout
diff --git a/src/tailwind.css b/app/core/styles/index.css
similarity index 100%
rename from src/tailwind.css
rename to app/core/styles/index.css
diff --git a/app/customers/queries/get-current-customer.ts b/app/customers/queries/get-current-customer.ts
new file mode 100644
index 0000000..4870eec
--- /dev/null
+++ b/app/customers/queries/get-current-customer.ts
@@ -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,
+ },
+ })
+}
diff --git a/app/messages/api/webhook/incoming-message.ts b/app/messages/api/webhook/incoming-message.ts
new file mode 100644
index 0000000..51b5f1f
--- /dev/null
+++ b/app/messages/api/webhook/incoming-message.ts
@@ -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
+ }
+}
diff --git a/app/messages/components/conversation.tsx b/app/messages/components/conversation.tsx
new file mode 100644
index 0000000..93d1c1d
--- /dev/null
+++ b/app/messages/components/conversation.tsx
@@ -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(null)
+
+ useEffect(() => {
+ messagesListRef.current?.querySelector("li:last-child")?.scrollIntoView()
+ }, [conversation, messagesListRef])
+
+ return (
+ <>
+
+
+ {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 (
+ -
+ {(!isSamePrevious || isTooLate) && (
+
+
+ {new Date(message.sentAt).toLocaleDateString("fr-FR", {
+ weekday: "long",
+ day: "2-digit",
+ month: "short",
+ })}
+
+
+ {new Date(message.sentAt).toLocaleTimeString("fr-FR", {
+ hour: "2-digit",
+ minute: "2-digit",
+ })}
+
+
+ )}
+
+
+
+ {message.content}
+
+
+
+ )
+ })}
+
+
+
+
+
+ >
+ )
+}
diff --git a/app/messages/components/conversations-list.tsx b/app/messages/components/conversations-list.tsx
new file mode 100644
index 0000000..34b68de
--- /dev/null
+++ b/app/messages/components/conversations-list.tsx
@@ -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 empty state
+ }
+
+ return (
+
+ )
+}
diff --git a/app/messages/components/new-message-area.tsx b/app/messages/components/new-message-area.tsx
new file mode 100644
index 0000000..7356226
--- /dev/null
+++ b/app/messages/components/new-message-area.tsx
@@ -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
+ )
+}
+
+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)
+ })
+}
diff --git a/app/messages/hooks/use-conversation.ts b/app/messages/hooks/use-conversation.ts
new file mode 100644
index 0000000..e07da39
--- /dev/null
+++ b/app/messages/hooks/use-conversation.ts
@@ -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]!
+ },
+ }
+ )
+}
diff --git a/app/messages/mutations/send-message.ts b/app/messages/mutations/send-message.ts
new file mode 100644
index 0000000..127e77b
--- /dev/null
+++ b/app/messages/mutations/send-message.ts
@@ -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,
+ }
+ )
+ }
+)
diff --git a/app/messages/pages/messages.tsx b/app/messages/pages/messages.tsx
new file mode 100644
index 0000000..886035d
--- /dev/null
+++ b/app/messages/pages/messages.tsx
@@ -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 (
+
+
+
+
+
+
+ )
+}
+
+Messages.authenticate = true
+
+export default Messages
diff --git a/app/messages/pages/messages/[recipient].tsx b/app/messages/pages/messages/[recipient].tsx
new file mode 100644
index 0000000..8c4cf0f
--- /dev/null
+++ b/app/messages/pages/messages/[recipient].tsx
@@ -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 (
+
+
+
+
+
+ {recipient}
+
+
+
+
+
+ Loading messages with {recipient}}>
+
+
+
+ )
+}
+
+ConversationPage.authenticate = true
+
+export default ConversationPage
diff --git a/app/messages/queries/get-conversation.ts b/app/messages/queries/get-conversation.ts
new file mode 100644
index 0000000..d20ed93
--- /dev/null
+++ b/app/messages/queries/get-conversation.ts
@@ -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),
+ }
+ })
+ }
+)
diff --git a/app/messages/queries/get-conversations.ts b/app/messages/queries/get-conversations.ts
new file mode 100644
index 0000000..ce12f90
--- /dev/null
+++ b/app/messages/queries/get-conversations.ts
@@ -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 = {}
+ 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
+})
diff --git a/src/components/welcome/onboarding-layout.tsx b/app/onboarding/components/onboarding-layout.tsx
similarity index 52%
rename from src/components/welcome/onboarding-layout.tsx
rename to app/onboarding/components/onboarding-layout.tsx
index cc3664e..1c70eca 100644
--- a/src/components/welcome/onboarding-layout.tsx
+++ b/app/onboarding/components/onboarding-layout.tsx
@@ -1,71 +1,73 @@
-import type { FunctionComponent } from "react";
-import { CheckIcon } from "@heroicons/react/solid";
-import clsx from "clsx";
-import Link from "next/link";
+import type { FunctionComponent } from "react"
+import { CheckIcon } from "@heroicons/react/solid"
+import clsx from "clsx"
+import { Link, Routes, useRouter } from "blitz"
+
+import useCustomerPhoneNumber from "../../core/hooks/use-customer-phone-number"
type StepLink = {
- href: string;
- label: string;
+ href: string
+ label: string
}
type Props = {
- currentStep: 1 | 2 | 3;
- previous?: StepLink;
- next?: StepLink;
-};
+ currentStep: 1 | 2 | 3
+ previous?: StepLink
+ next?: StepLink
+}
-const steps = [
- "Welcome",
- "Twilio Credentials",
- "Pick a plan",
-] as const;
+const steps = ["Welcome", "Twilio Credentials", "Pick a plan"] as const
+
+const OnboardingLayout: FunctionComponent = ({ children, currentStep, previous, next }) => {
+ const router = useRouter()
+ const customerPhoneNumber = useCustomerPhoneNumber()
+
+ if (customerPhoneNumber) {
+ throw router.push(Routes.Messages())
+ }
-const OnboardingLayout: FunctionComponent = ({
- children,
- currentStep,
- previous,
- next,
-}) => {
return (
{/* This element is to trick the browser into centering the modal contents. */}
-
-
-
+
-
{steps[currentStep - 1]}
+
+ {steps[currentStep - 1]}
+
- );
-};
+ )
+}
-export default OnboardingLayout;
+export default OnboardingLayout
diff --git a/app/onboarding/mutations/set-phone-number.ts b/app/onboarding/mutations/set-phone-number.ts
new file mode 100644
index 0000000..74074dd
--- /dev/null
+++ b/app/onboarding/mutations/set-phone-number.ts
@@ -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}` }),
+ ])
+ }
+)
diff --git a/app/onboarding/mutations/set-twilio-api-fields.ts b/app/onboarding/mutations/set-twilio-api-fields.ts
new file mode 100644
index 0000000..4e7a3de
--- /dev/null
+++ b/app/onboarding/mutations/set-twilio-api-fields.ts
@@ -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,
+ },
+ })
+ }
+)
diff --git a/app/onboarding/pages/welcome/step-one.tsx b/app/onboarding/pages/welcome/step-one.tsx
new file mode 100644
index 0000000..acecb3c
--- /dev/null
+++ b/app/onboarding/pages/welcome/step-one.tsx
@@ -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 (
+
+
+ Welcome, let’s set up your virtual phone!
+
+
+ )
+}
+
+StepOne.authenticate = true
+
+export default StepOne
diff --git a/app/onboarding/pages/welcome/step-three.tsx b/app/onboarding/pages/welcome/step-three.tsx
new file mode 100644
index 0000000..c173c3f
--- /dev/null
+++ b/app/onboarding/pages/welcome/step-three.tsx
@@ -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
= ({ availablePhoneNumbers }) => {
+ const {
+ register,
+ handleSubmit,
+ setValue,
+ formState: { isSubmitting },
+ } = useForm