From fc4278ca7b0e113374e7a0592f77161c209fedfd Mon Sep 17 00:00:00 2001
From: m5r
Date: Sat, 31 Jul 2021 22:33:18 +0800
Subject: [PATCH] migrate to blitzjs
---
.babelrc | 8 -
.editorconfig | 11 +
.eslintrc | 3 -
.eslintrc.js | 3 +
.gitignore | 58 +-
.husky/.gitignore | 1 +
.husky/pre-commit | 5 +
.husky/pre-push | 6 +
.idea/.gitignore | 5 +
.idea/modules.xml | 8 +
.idea/vcs.xml | 6 +
.idea/virtual-phone.blitz.iml | 13 +
.npmrc | 2 +
.prettierignore | 7 +
.travis.yml | 11 -
.vscode/extensions.json | 12 +
.vscode/settings.json | 7 +
app/api/_types.ts | 4 +
app/api/ddd.ts | 16 +
.../api/newsletter/_mailchimp.ts | 20 +-
app/api/newsletter/subscribe.ts | 59 +
app/api/queue/fetch-calls.ts | 38 +
app/api/queue/fetch-messages.ts | 38 +
app/api/queue/insert-calls.ts | 59 +
app/api/queue/insert-messages.ts | 78 +
app/api/queue/send-message.ts | 34 +
app/api/queue/set-twilio-webhooks.ts | 43 +
app/auth/components/login-form.tsx | 61 +
app/auth/components/signup-form.tsx | 49 +
app/auth/mutations/change-password.ts | 24 +
app/auth/mutations/forgot-password.test.ts | 61 +
app/auth/mutations/forgot-password.ts | 42 +
app/auth/mutations/login.ts | 31 +
app/auth/mutations/logout.ts | 5 +
app/auth/mutations/reset-password.test.ts | 83 +
app/auth/mutations/reset-password.ts | 48 +
app/auth/mutations/signup.ts | 18 +
app/auth/pages/forgot-password.tsx | 52 +
app/auth/pages/login.tsx | 26 +
app/auth/pages/reset-password.tsx | 65 +
app/auth/pages/signup.tsx | 19 +
app/auth/validations.ts | 33 +
app/core/components/form.tsx | 84 +
app/core/components/labeled-text-field.tsx | 58 +
app/core/hooks/use-current-customer.ts | 11 +
app/core/hooks/use-customer-phone-number.ts | 15 +
app/core/hooks/use-require-onboarding.ts | 24 +
app/core/layouts/base-layout.tsx | 22 +
.../core/layouts}/layout/footer.tsx | 37 +-
.../core/layouts}/layout/index.tsx | 54 +-
src/tailwind.css => app/core/styles/index.css | 0
app/customers/queries/get-current-customer.ts | 21 +
app/messages/api/webhook/incoming-message.ts | 131 +
app/messages/components/conversation.tsx | 82 +
.../components/conversations-list.tsx | 34 +
app/messages/components/new-message-area.tsx | 94 +
app/messages/hooks/use-conversation.ts | 19 +
app/messages/mutations/send-message.ts | 47 +
app/messages/pages/messages.tsx | 25 +
app/messages/pages/messages/[recipient].tsx | 39 +
app/messages/queries/get-conversation.ts | 31 +
app/messages/queries/get-conversations.ts | 41 +
.../components}/onboarding-layout.tsx | 100 +-
app/onboarding/mutations/set-phone-number.ts | 40 +
.../mutations/set-twilio-api-fields.ts | 26 +
app/onboarding/pages/welcome/step-one.tsx | 23 +
app/onboarding/pages/welcome/step-three.tsx | 124 +
.../onboarding}/pages/welcome/step-two.tsx | 81 +-
app/pages/404.tsx | 19 +
app/pages/_app.tsx | 49 +
app/pages/_document.tsx | 23 +
app/pages/index.test.tsx | 39 +
app/pages/index.tsx | 273 +
app/phone-calls/api/webhook/incoming-call.ts | 3 +
app/phone-calls/api/webhook/outgoing-call.ts | 3 +
.../components/phone-calls-list.tsx | 24 +
app/phone-calls/hooks/use-phone-calls.ts | 15 +
app/phone-calls/pages/calls.tsx | 25 +
app/phone-calls/queries/get-phone-calls.ts | 32 +
.../get-current-customer-phone-number.ts | 16 +
.../queries/get-customer-phone-number.ts | 19 +
babel.config.js | 4 +
blitz.config.ts | 36 +
db/_encryption.ts | 37 +
db/index.ts | 7 +
.../20210726100838_init/migration.sql | 57 +
.../migration.sql | 137 +
.../migration.sql | 12 +
.../migration.sql | 5 +
db/migrations/migration_lock.toml | 3 +
db/schema.prisma | 154 +
db/seeds.ts | 16 +
global.d.ts | 3 +
{lib => integrations}/logger.ts | 6 +-
jest.config.js | 16 +-
jest/helpers.ts | 104 -
jest/setup.ts | 24 -
jest/testing-library.ts | 1 -
lib/__tests__/session-helpers.ts | 30 -
lib/session-helpers.ts | 183 -
lib/utils/cookies.ts | 79 -
lib/utils/hkdf.ts | 7 -
mailers/forgot-password-mailer.ts | 47 +
next-env.d.ts | 3 -
next.config.js | 104 -
package-lock.json | 27781 +++++++++-------
package.json | 158 +-
postcss.config.js | 7 +-
public/favicon.ico | Bin 0 -> 556 bytes
public/logo.png | Bin 0 -> 33469 bytes
public/robots.txt | 2 -
public/static/favicon.ico | Bin 15086 -> 0 bytes
.../static/fonts/inter/Inter-italic.var.woff2 | Bin 241052 -> 0 bytes
.../static/fonts/inter/Inter-roman.var.woff2 | Bin 226100 -> 0 bytes
.../static/illustrations/data-analytics.svg | 5714 ----
public/static/illustrations/learn-coding.svg | 386 -
public/static/illustrations/support-team.svg | 463 -
public/static/logo.svg | 31 -
.../pages/__snapshots__/index.tsx.snap | 330 -
.../pages/account/settings/index.tsx | 171 -
src/__tests__/pages/account/settings/team.tsx | 323 -
src/__tests__/pages/api/auth/sign-in.ts | 221 -
src/__tests__/pages/api/auth/sign-up.ts | 76 -
.../api/subscription/_subscription-created.ts | 164 -
.../pages/api/subscription/webhook.ts | 111 -
src/__tests__/pages/api/team/invite-member.ts | 90 -
src/__tests__/pages/api/user/session.ts | 21 -
src/__tests__/pages/api/user/update-user.ts | 112 -
src/__tests__/pages/auth/sign-in.tsx | 51 -
src/__tests__/pages/index.tsx | 46 -
src/__tests__/pages/team/invitation.tsx | 131 -
src/components/alert.tsx | 115 -
src/components/auth/auth-page.tsx | 224 -
src/components/avatar.tsx | 15 -
src/components/billing/billing-plans.tsx | 261 -
src/components/button.tsx | 58 -
src/components/connected-layout.tsx | 158 -
src/components/divider.tsx | 9 -
src/components/icons.tsx | 5 -
src/components/layout/header.tsx | 90 -
src/components/loading.tsx | 23 -
src/components/logo.tsx | 15 -
src/components/modal.tsx | 71 -
src/components/outside-alerter.tsx | 33 -
src/components/settings/danger-zone.tsx | 110 -
.../settings/profile-informations.tsx | 138 -
src/components/settings/settings-layout.tsx | 52 -
src/components/settings/settings-section.tsx | 26 -
src/components/settings/update-password.tsx | 141 -
src/components/toggle.tsx | 41 -
src/database/_encryption.ts | 33 -
src/database/customer.ts | 68 -
src/database/message.ts | 86 -
src/database/phone-call.ts | 53 -
src/database/phone-number.ts | 67 -
src/database/subscriptions.ts | 151 -
src/fonts.css | 17 -
src/hooks/use-auth.ts | 67 -
src/hooks/use-conversation.ts | 71 -
src/hooks/use-paddle.ts | 49 -
src/hooks/use-request.ts | 22 -
src/hooks/use-subscription.ts | 92 -
src/hooks/use-user.ts | 52 -
src/pages/_app.tsx | 36 -
src/pages/_document.tsx | 60 -
src/pages/api/_redirect.ts | 6 -
src/pages/api/_send-email.ts | 45 -
src/pages/api/_types.ts | 4 -
src/pages/api/auth/session.ts | 10 -
src/pages/api/auth/sign-in.ts | 71 -
src/pages/api/auth/sign-up.ts | 94 -
src/pages/api/conversation/[recipient].ts | 54 -
.../conversation/[recipient]/send-message.ts | 80 -
src/pages/api/ddd.ts | 55 -
src/pages/api/newsletter/subscribe.ts | 65 -
src/pages/api/queue/fetch-calls.ts | 40 -
src/pages/api/queue/fetch-messages.ts | 40 -
src/pages/api/queue/insert-calls.ts | 32 -
src/pages/api/queue/insert-messages.ts | 37 -
src/pages/api/queue/send-message.ts | 34 -
src/pages/api/queue/set-twilio-webhooks.ts | 40 -
.../subscription/_subscription-cancelled.ts | 92 -
.../api/subscription/_subscription-created.ts | 131 -
.../_subscription-payment-succeeded.ts | 90 -
.../api/subscription/_subscription-updated.ts | 123 -
src/pages/api/subscription/update.ts | 88 -
src/pages/api/subscription/webhook.ts | 78 -
src/pages/api/user/add-phone-number.ts | 58 -
src/pages/api/user/delete-user.ts | 50 -
src/pages/api/user/list-twilio-numbers.ts | 21 -
src/pages/api/user/update-user.ts | 115 -
src/pages/api/webhook/incoming-call.ts | 5 -
src/pages/api/webhook/incoming-message.ts | 76 -
src/pages/api/webhook/outgoing-call.ts | 5 -
src/pages/auth/forgot-password.tsx | 127 -
src/pages/auth/sign-in.tsx | 12 -
src/pages/auth/sign-out.tsx | 34 -
src/pages/auth/sign-up.tsx | 12 -
src/pages/calls.tsx | 37 -
src/pages/index.tsx | 268 -
src/pages/keypad.tsx | 119 -
src/pages/messages/[recipient].tsx | 173 -
src/pages/messages/index.tsx | 51 -
src/pages/settings/account.tsx | 56 -
src/pages/settings/billing.tsx | 109 -
src/pages/settings/index.tsx | 55 -
src/pages/welcome/step-one.tsx | 22 -
src/pages/welcome/step-three.tsx | 107 -
src/state.ts | 50 -
src/subscription/_paddle-api.ts | 51 -
src/subscription/plans.ts | 61 -
src/supabase/client.ts | 10 -
src/supabase/server.ts | 11 -
tailwind.config.js | 37 +-
test/setup.ts | 4 +
test/utils.tsx | 105 +
tsconfig.json | 23 +-
types.ts | 17 +
218 files changed, 19100 insertions(+), 27038 deletions(-)
delete mode 100644 .babelrc
create mode 100644 .editorconfig
delete mode 100644 .eslintrc
create mode 100644 .eslintrc.js
create mode 100644 .husky/.gitignore
create mode 100755 .husky/pre-commit
create mode 100755 .husky/pre-push
create mode 100644 .idea/.gitignore
create mode 100644 .idea/modules.xml
create mode 100644 .idea/vcs.xml
create mode 100644 .idea/virtual-phone.blitz.iml
create mode 100644 .npmrc
create mode 100644 .prettierignore
delete mode 100644 .travis.yml
create mode 100644 .vscode/extensions.json
create mode 100644 .vscode/settings.json
create mode 100644 app/api/_types.ts
create mode 100644 app/api/ddd.ts
rename {src/pages => app}/api/newsletter/_mailchimp.ts (54%)
create mode 100644 app/api/newsletter/subscribe.ts
create mode 100644 app/api/queue/fetch-calls.ts
create mode 100644 app/api/queue/fetch-messages.ts
create mode 100644 app/api/queue/insert-calls.ts
create mode 100644 app/api/queue/insert-messages.ts
create mode 100644 app/api/queue/send-message.ts
create mode 100644 app/api/queue/set-twilio-webhooks.ts
create mode 100644 app/auth/components/login-form.tsx
create mode 100644 app/auth/components/signup-form.tsx
create mode 100644 app/auth/mutations/change-password.ts
create mode 100644 app/auth/mutations/forgot-password.test.ts
create mode 100644 app/auth/mutations/forgot-password.ts
create mode 100644 app/auth/mutations/login.ts
create mode 100644 app/auth/mutations/logout.ts
create mode 100644 app/auth/mutations/reset-password.test.ts
create mode 100644 app/auth/mutations/reset-password.ts
create mode 100644 app/auth/mutations/signup.ts
create mode 100644 app/auth/pages/forgot-password.tsx
create mode 100644 app/auth/pages/login.tsx
create mode 100644 app/auth/pages/reset-password.tsx
create mode 100644 app/auth/pages/signup.tsx
create mode 100644 app/auth/validations.ts
create mode 100644 app/core/components/form.tsx
create mode 100644 app/core/components/labeled-text-field.tsx
create mode 100644 app/core/hooks/use-current-customer.ts
create mode 100644 app/core/hooks/use-customer-phone-number.ts
create mode 100644 app/core/hooks/use-require-onboarding.ts
create mode 100644 app/core/layouts/base-layout.tsx
rename {src/components => app/core/layouts}/layout/footer.tsx (75%)
rename {src/components => app/core/layouts}/layout/index.tsx (72%)
rename src/tailwind.css => app/core/styles/index.css (100%)
create mode 100644 app/customers/queries/get-current-customer.ts
create mode 100644 app/messages/api/webhook/incoming-message.ts
create mode 100644 app/messages/components/conversation.tsx
create mode 100644 app/messages/components/conversations-list.tsx
create mode 100644 app/messages/components/new-message-area.tsx
create mode 100644 app/messages/hooks/use-conversation.ts
create mode 100644 app/messages/mutations/send-message.ts
create mode 100644 app/messages/pages/messages.tsx
create mode 100644 app/messages/pages/messages/[recipient].tsx
create mode 100644 app/messages/queries/get-conversation.ts
create mode 100644 app/messages/queries/get-conversations.ts
rename {src/components/welcome => app/onboarding/components}/onboarding-layout.tsx (52%)
create mode 100644 app/onboarding/mutations/set-phone-number.ts
create mode 100644 app/onboarding/mutations/set-twilio-api-fields.ts
create mode 100644 app/onboarding/pages/welcome/step-one.tsx
create mode 100644 app/onboarding/pages/welcome/step-three.tsx
rename {src => app/onboarding}/pages/welcome/step-two.tsx (55%)
create mode 100644 app/pages/404.tsx
create mode 100644 app/pages/_app.tsx
create mode 100644 app/pages/_document.tsx
create mode 100644 app/pages/index.test.tsx
create mode 100644 app/pages/index.tsx
create mode 100644 app/phone-calls/api/webhook/incoming-call.ts
create mode 100644 app/phone-calls/api/webhook/outgoing-call.ts
create mode 100644 app/phone-calls/components/phone-calls-list.tsx
create mode 100644 app/phone-calls/hooks/use-phone-calls.ts
create mode 100644 app/phone-calls/pages/calls.tsx
create mode 100644 app/phone-calls/queries/get-phone-calls.ts
create mode 100644 app/phone-numbers/queries/get-current-customer-phone-number.ts
create mode 100644 app/phone-numbers/queries/get-customer-phone-number.ts
create mode 100644 babel.config.js
create mode 100644 blitz.config.ts
create mode 100644 db/_encryption.ts
create mode 100644 db/index.ts
create mode 100644 db/migrations/20210726100838_init/migration.sql
create mode 100644 db/migrations/20210727115631_import_models/migration.sql
create mode 100644 db/migrations/20210727125716_user_role_enum/migration.sql
create mode 100644 db/migrations/20210728061812_default_created_at_to_now/migration.sql
create mode 100644 db/migrations/migration_lock.toml
create mode 100644 db/schema.prisma
create mode 100644 db/seeds.ts
create mode 100644 global.d.ts
rename {lib => integrations}/logger.ts (76%)
delete mode 100644 jest/helpers.ts
delete mode 100644 jest/setup.ts
delete mode 100644 jest/testing-library.ts
delete mode 100644 lib/__tests__/session-helpers.ts
delete mode 100644 lib/session-helpers.ts
delete mode 100644 lib/utils/cookies.ts
delete mode 100644 lib/utils/hkdf.ts
create mode 100644 mailers/forgot-password-mailer.ts
delete mode 100644 next-env.d.ts
delete mode 100644 next.config.js
create mode 100755 public/favicon.ico
create mode 100644 public/logo.png
delete mode 100644 public/robots.txt
delete mode 100644 public/static/favicon.ico
delete mode 100644 public/static/fonts/inter/Inter-italic.var.woff2
delete mode 100644 public/static/fonts/inter/Inter-roman.var.woff2
delete mode 100644 public/static/illustrations/data-analytics.svg
delete mode 100644 public/static/illustrations/learn-coding.svg
delete mode 100644 public/static/illustrations/support-team.svg
delete mode 100644 public/static/logo.svg
delete mode 100644 src/__tests__/pages/__snapshots__/index.tsx.snap
delete mode 100644 src/__tests__/pages/account/settings/index.tsx
delete mode 100644 src/__tests__/pages/account/settings/team.tsx
delete mode 100644 src/__tests__/pages/api/auth/sign-in.ts
delete mode 100644 src/__tests__/pages/api/auth/sign-up.ts
delete mode 100644 src/__tests__/pages/api/subscription/_subscription-created.ts
delete mode 100644 src/__tests__/pages/api/subscription/webhook.ts
delete mode 100644 src/__tests__/pages/api/team/invite-member.ts
delete mode 100644 src/__tests__/pages/api/user/session.ts
delete mode 100644 src/__tests__/pages/api/user/update-user.ts
delete mode 100644 src/__tests__/pages/auth/sign-in.tsx
delete mode 100644 src/__tests__/pages/index.tsx
delete mode 100644 src/__tests__/pages/team/invitation.tsx
delete mode 100644 src/components/alert.tsx
delete mode 100644 src/components/auth/auth-page.tsx
delete mode 100644 src/components/avatar.tsx
delete mode 100644 src/components/billing/billing-plans.tsx
delete mode 100644 src/components/button.tsx
delete mode 100644 src/components/connected-layout.tsx
delete mode 100644 src/components/divider.tsx
delete mode 100644 src/components/icons.tsx
delete mode 100644 src/components/layout/header.tsx
delete mode 100644 src/components/loading.tsx
delete mode 100644 src/components/logo.tsx
delete mode 100644 src/components/modal.tsx
delete mode 100644 src/components/outside-alerter.tsx
delete mode 100644 src/components/settings/danger-zone.tsx
delete mode 100644 src/components/settings/profile-informations.tsx
delete mode 100644 src/components/settings/settings-layout.tsx
delete mode 100644 src/components/settings/settings-section.tsx
delete mode 100644 src/components/settings/update-password.tsx
delete mode 100644 src/components/toggle.tsx
delete mode 100644 src/database/_encryption.ts
delete mode 100644 src/database/customer.ts
delete mode 100644 src/database/message.ts
delete mode 100644 src/database/phone-call.ts
delete mode 100644 src/database/phone-number.ts
delete mode 100644 src/database/subscriptions.ts
delete mode 100644 src/fonts.css
delete mode 100644 src/hooks/use-auth.ts
delete mode 100644 src/hooks/use-conversation.ts
delete mode 100644 src/hooks/use-paddle.ts
delete mode 100644 src/hooks/use-request.ts
delete mode 100644 src/hooks/use-subscription.ts
delete mode 100644 src/hooks/use-user.ts
delete mode 100644 src/pages/_app.tsx
delete mode 100644 src/pages/_document.tsx
delete mode 100644 src/pages/api/_redirect.ts
delete mode 100644 src/pages/api/_send-email.ts
delete mode 100644 src/pages/api/_types.ts
delete mode 100644 src/pages/api/auth/session.ts
delete mode 100644 src/pages/api/auth/sign-in.ts
delete mode 100644 src/pages/api/auth/sign-up.ts
delete mode 100644 src/pages/api/conversation/[recipient].ts
delete mode 100644 src/pages/api/conversation/[recipient]/send-message.ts
delete mode 100644 src/pages/api/ddd.ts
delete mode 100644 src/pages/api/newsletter/subscribe.ts
delete mode 100644 src/pages/api/queue/fetch-calls.ts
delete mode 100644 src/pages/api/queue/fetch-messages.ts
delete mode 100644 src/pages/api/queue/insert-calls.ts
delete mode 100644 src/pages/api/queue/insert-messages.ts
delete mode 100644 src/pages/api/queue/send-message.ts
delete mode 100644 src/pages/api/queue/set-twilio-webhooks.ts
delete mode 100644 src/pages/api/subscription/_subscription-cancelled.ts
delete mode 100644 src/pages/api/subscription/_subscription-created.ts
delete mode 100644 src/pages/api/subscription/_subscription-payment-succeeded.ts
delete mode 100644 src/pages/api/subscription/_subscription-updated.ts
delete mode 100644 src/pages/api/subscription/update.ts
delete mode 100644 src/pages/api/subscription/webhook.ts
delete mode 100644 src/pages/api/user/add-phone-number.ts
delete mode 100644 src/pages/api/user/delete-user.ts
delete mode 100644 src/pages/api/user/list-twilio-numbers.ts
delete mode 100644 src/pages/api/user/update-user.ts
delete mode 100644 src/pages/api/webhook/incoming-call.ts
delete mode 100644 src/pages/api/webhook/incoming-message.ts
delete mode 100644 src/pages/api/webhook/outgoing-call.ts
delete mode 100644 src/pages/auth/forgot-password.tsx
delete mode 100644 src/pages/auth/sign-in.tsx
delete mode 100644 src/pages/auth/sign-out.tsx
delete mode 100644 src/pages/auth/sign-up.tsx
delete mode 100644 src/pages/calls.tsx
delete mode 100644 src/pages/index.tsx
delete mode 100644 src/pages/keypad.tsx
delete mode 100644 src/pages/messages/[recipient].tsx
delete mode 100644 src/pages/messages/index.tsx
delete mode 100644 src/pages/settings/account.tsx
delete mode 100644 src/pages/settings/billing.tsx
delete mode 100644 src/pages/settings/index.tsx
delete mode 100644 src/pages/welcome/step-one.tsx
delete mode 100644 src/pages/welcome/step-three.tsx
delete mode 100644 src/state.ts
delete mode 100644 src/subscription/_paddle-api.ts
delete mode 100644 src/subscription/plans.ts
delete mode 100644 src/supabase/client.ts
delete mode 100644 src/supabase/server.ts
create mode 100644 test/setup.ts
create mode 100644 test/utils.tsx
create mode 100644 types.ts
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