list conversations
send sms begin webhooks
This commit is contained in:
parent
3762305c4f
commit
6a12d0cd93
@ -1,17 +0,0 @@
|
||||
version: '3.1'
|
||||
services :
|
||||
db:
|
||||
image: postgres:13-alpine
|
||||
ports:
|
||||
- "5432:5432"
|
||||
environment:
|
||||
POSTGRES_USER: user
|
||||
POSTGRES_PASSWORD: pass
|
||||
POSTGRES_DB: apidb
|
||||
admin:
|
||||
image: adminer
|
||||
restart: always
|
||||
depends_on:
|
||||
- db
|
||||
ports:
|
||||
- 8080:8080
|
323
package-lock.json
generated
323
package-lock.json
generated
@ -2065,11 +2065,23 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz",
|
||||
"integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw=="
|
||||
},
|
||||
"@types/minimist": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.2.tgz",
|
||||
"integrity": "sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/node": {
|
||||
"version": "15.12.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-15.12.4.tgz",
|
||||
"integrity": "sha512-zrNj1+yqYF4WskCMOHwN+w9iuD12+dGm0rQ35HLl9/Ouuq52cEtd0CH9qMgrdNmi5ejC1/V7vKEXYubB+65DkA=="
|
||||
},
|
||||
"@types/normalize-package-data": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.1.tgz",
|
||||
"integrity": "sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/on-headers": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/on-headers/-/on-headers-1.0.0.tgz",
|
||||
@ -3234,6 +3246,31 @@
|
||||
"integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
|
||||
"dev": true
|
||||
},
|
||||
"camelcase-keys": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-6.2.2.tgz",
|
||||
"integrity": "sha512-YrwaA0vEKazPBkn0ipTiMpSajYDSe+KjQfrjhcBMxJt/znbvlHd8Pw/Vamaz5EB4Wfhs3SUR3Z9mwRu/P3s3Yg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"camelcase": "^5.3.1",
|
||||
"map-obj": "^4.0.0",
|
||||
"quick-lru": "^4.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"camelcase": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||
"dev": true
|
||||
},
|
||||
"quick-lru": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-4.0.1.tgz",
|
||||
"integrity": "sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"caniuse-lite": {
|
||||
"version": "1.0.30001242",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001242.tgz",
|
||||
@ -4005,6 +4042,30 @@
|
||||
"ms": "2.1.2"
|
||||
}
|
||||
},
|
||||
"decamelize": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||
"integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=",
|
||||
"dev": true
|
||||
},
|
||||
"decamelize-keys": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/decamelize-keys/-/decamelize-keys-1.1.0.tgz",
|
||||
"integrity": "sha1-0XGoeTMlKAfrPLYdwcFEXQeN8tk=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"decamelize": "^1.1.0",
|
||||
"map-obj": "^1.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"map-obj": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/map-obj/-/map-obj-1.0.1.tgz",
|
||||
"integrity": "sha1-2TPOuSBdgr3PSIb2dCvcK03qFG0=",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"decimal.js": {
|
||||
"version": "10.3.1",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.3.1.tgz",
|
||||
@ -5738,6 +5799,12 @@
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz",
|
||||
"integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA=="
|
||||
},
|
||||
"globalyzer": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/globalyzer/-/globalyzer-0.1.0.tgz",
|
||||
"integrity": "sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q==",
|
||||
"dev": true
|
||||
},
|
||||
"globby": {
|
||||
"version": "11.0.4",
|
||||
"resolved": "https://registry.npmjs.org/globby/-/globby-11.0.4.tgz",
|
||||
@ -5760,6 +5827,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"globrex": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz",
|
||||
"integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==",
|
||||
"dev": true
|
||||
},
|
||||
"google-auth-library": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-7.2.0.tgz",
|
||||
@ -5846,6 +5919,12 @@
|
||||
"jws": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"hard-rejection": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/hard-rejection/-/hard-rejection-2.1.0.tgz",
|
||||
"integrity": "sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA==",
|
||||
"dev": true
|
||||
},
|
||||
"has": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
|
||||
@ -6369,6 +6448,12 @@
|
||||
"integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==",
|
||||
"optional": true
|
||||
},
|
||||
"is-plain-obj": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz",
|
||||
"integrity": "sha1-caUMhCnfync8kqOQpKA7OfzVHT4=",
|
||||
"dev": true
|
||||
},
|
||||
"is-potential-custom-element-name": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
|
||||
@ -7961,6 +8046,12 @@
|
||||
"json-buffer": "3.0.1"
|
||||
}
|
||||
},
|
||||
"kind-of": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
|
||||
"integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
|
||||
"dev": true
|
||||
},
|
||||
"kleur": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
|
||||
@ -8298,6 +8389,12 @@
|
||||
"tmpl": "1.0.x"
|
||||
}
|
||||
},
|
||||
"map-obj": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/map-obj/-/map-obj-4.2.1.tgz",
|
||||
"integrity": "sha512-+WA2/1sPmDj1dlvvJmB5G6JKfY9dpn7EVBUL06+y6PoljPkh+6V1QihwxNkbcGxCRjt2b0F9K0taiCuo7MbdFQ==",
|
||||
"dev": true
|
||||
},
|
||||
"match-sorter": {
|
||||
"version": "6.3.0",
|
||||
"resolved": "https://registry.npmjs.org/match-sorter/-/match-sorter-6.3.0.tgz",
|
||||
@ -8332,6 +8429,139 @@
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||
"integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g="
|
||||
},
|
||||
"meow": {
|
||||
"version": "9.0.0",
|
||||
"resolved": "https://registry.npmjs.org/meow/-/meow-9.0.0.tgz",
|
||||
"integrity": "sha512-+obSblOQmRhcyBt62furQqRAQpNyWXo8BuQ5bN7dG8wmwQ+vwHKp/rCFD4CrTP8CsDQD1sjoZ94K417XEUk8IQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/minimist": "^1.2.0",
|
||||
"camelcase-keys": "^6.2.2",
|
||||
"decamelize": "^1.2.0",
|
||||
"decamelize-keys": "^1.1.0",
|
||||
"hard-rejection": "^2.1.0",
|
||||
"minimist-options": "4.1.0",
|
||||
"normalize-package-data": "^3.0.0",
|
||||
"read-pkg-up": "^7.0.1",
|
||||
"redent": "^3.0.0",
|
||||
"trim-newlines": "^3.0.0",
|
||||
"type-fest": "^0.18.0",
|
||||
"yargs-parser": "^20.2.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"hosted-git-info": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.0.2.tgz",
|
||||
"integrity": "sha512-c9OGXbZ3guC/xOlCg1Ci/VgWlwsqDv1yMQL1CWqXDL0hDjXuNcq0zuR4xqPSuasI3kqFDhqSyTjREz5gzq0fXg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"lru-cache": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"normalize-package-data": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-3.0.2.tgz",
|
||||
"integrity": "sha512-6CdZocmfGaKnIHPVFhJJZ3GuR8SsLKvDANFp47Jmy51aKIr8akjAWTSxtpI+MBgBFdSMRyo4hMpDlT6dTffgZg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"hosted-git-info": "^4.0.1",
|
||||
"resolve": "^1.20.0",
|
||||
"semver": "^7.3.4",
|
||||
"validate-npm-package-license": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"parse-json": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
|
||||
"integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/code-frame": "^7.0.0",
|
||||
"error-ex": "^1.3.1",
|
||||
"json-parse-even-better-errors": "^2.3.0",
|
||||
"lines-and-columns": "^1.1.6"
|
||||
}
|
||||
},
|
||||
"read-pkg": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz",
|
||||
"integrity": "sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/normalize-package-data": "^2.4.0",
|
||||
"normalize-package-data": "^2.5.0",
|
||||
"parse-json": "^5.0.0",
|
||||
"type-fest": "^0.6.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"hosted-git-info": {
|
||||
"version": "2.8.9",
|
||||
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz",
|
||||
"integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==",
|
||||
"dev": true
|
||||
},
|
||||
"normalize-package-data": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz",
|
||||
"integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"hosted-git-info": "^2.1.4",
|
||||
"resolve": "^1.10.0",
|
||||
"semver": "2 || 3 || 4 || 5",
|
||||
"validate-npm-package-license": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"semver": {
|
||||
"version": "5.7.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
|
||||
"integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==",
|
||||
"dev": true
|
||||
},
|
||||
"type-fest": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.6.0.tgz",
|
||||
"integrity": "sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"read-pkg-up": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-7.0.1.tgz",
|
||||
"integrity": "sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"find-up": "^4.1.0",
|
||||
"read-pkg": "^5.2.0",
|
||||
"type-fest": "^0.8.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"type-fest": {
|
||||
"version": "0.8.1",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz",
|
||||
"integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"semver": {
|
||||
"version": "7.3.5",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz",
|
||||
"integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"lru-cache": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"type-fest": {
|
||||
"version": "0.18.1",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.18.1.tgz",
|
||||
"integrity": "sha512-OIAYXk8+ISY+qTOwkHtKqzAuxchoMiD9Udx+FSGQDuiRR+PJKJHc2NJAXlbhkGwTt/4/nKZxELY1w3ReWOL8mw==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"merge-stream": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
|
||||
@ -8380,8 +8610,7 @@
|
||||
"mime": {
|
||||
"version": "2.5.2",
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-2.5.2.tgz",
|
||||
"integrity": "sha512-tqkh47FzKeCPD2PUiPB6pkbMzsCasjxAfC62/Wap5qrUWcb+sFasXUC5I3gYM5iBM8v/Qpn4UK0x+j0iHyFPDg==",
|
||||
"optional": true
|
||||
"integrity": "sha512-tqkh47FzKeCPD2PUiPB6pkbMzsCasjxAfC62/Wap5qrUWcb+sFasXUC5I3gYM5iBM8v/Qpn4UK0x+j0iHyFPDg=="
|
||||
},
|
||||
"mime-db": {
|
||||
"version": "1.48.0",
|
||||
@ -8441,6 +8670,25 @@
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz",
|
||||
"integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw=="
|
||||
},
|
||||
"minimist-options": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/minimist-options/-/minimist-options-4.1.0.tgz",
|
||||
"integrity": "sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"arrify": "^1.0.1",
|
||||
"is-plain-obj": "^1.1.0",
|
||||
"kind-of": "^6.0.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"arrify": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz",
|
||||
"integrity": "sha1-iYUI2iIm84DfkEcoRWhJwVAaSw0=",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"mkdirp": {
|
||||
"version": "0.5.5",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz",
|
||||
@ -9105,6 +9353,55 @@
|
||||
"resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-9.1.0.tgz",
|
||||
"integrity": "sha512-mhXh8QN8sbErlxfxBeZ/pzgvmDn443p8CXlxwGSi2bWANZAFvjLPI0PoGjqHW+JdBbXg6uvmvM81WXaweh/SVA=="
|
||||
},
|
||||
"openapi-typescript": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-4.0.2.tgz",
|
||||
"integrity": "sha512-rDx7e2Jl01kRlsk+IJiqAD4oeb+2mDHcmxsM+rgUFY/oCqnzQ4sIiNLK+/ng6L6mq/eg+IivRUvf/5fVPcvUEg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"hosted-git-info": "^3.0.8",
|
||||
"js-yaml": "^4.1.0",
|
||||
"kleur": "^4.1.4",
|
||||
"meow": "^9.0.0",
|
||||
"mime": "^2.5.2",
|
||||
"node-fetch": "^2.6.1",
|
||||
"prettier": "^2.3.1",
|
||||
"slash": "^3.0.0",
|
||||
"tiny-glob": "^0.2.9"
|
||||
},
|
||||
"dependencies": {
|
||||
"argparse": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||
"dev": true
|
||||
},
|
||||
"hosted-git-info": {
|
||||
"version": "3.0.8",
|
||||
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-3.0.8.tgz",
|
||||
"integrity": "sha512-aXpmwoOhRBrw6X3j0h5RloK4x1OzsxMPyxqIHyNfSe2pypkVTZFpEiRoSipPEPlMrh0HW/XsjkJ5WgnCirpNUw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"lru-cache": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"js-yaml": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
|
||||
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"argparse": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"kleur": {
|
||||
"version": "4.1.4",
|
||||
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.4.tgz",
|
||||
"integrity": "sha512-8QADVssbrFjivHWQU7KkMgptGTl6WAcSdlbBPY4uNF+mWr6DGcKrvY2w4FQJoXch7+fKMjj0dRrL75vk3k23OA==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"openid-client": {
|
||||
"version": "4.7.4",
|
||||
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-4.7.4.tgz",
|
||||
@ -9598,6 +9895,12 @@
|
||||
"integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
|
||||
"dev": true
|
||||
},
|
||||
"prettier": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-2.3.2.tgz",
|
||||
"integrity": "sha512-lnJzDfJ66zkMy58OL5/NY5zp70S7Nz6KqcKkXYzn2tMVrNxvbqaBpg7H3qHaLxCJ5lNMsGuM8+ohS7cZrthdLQ==",
|
||||
"dev": true
|
||||
},
|
||||
"pretty-format": {
|
||||
"version": "27.0.6",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.0.6.tgz",
|
||||
@ -11245,6 +11548,16 @@
|
||||
"setimmediate": "^1.0.4"
|
||||
}
|
||||
},
|
||||
"tiny-glob": {
|
||||
"version": "0.2.9",
|
||||
"resolved": "https://registry.npmjs.org/tiny-glob/-/tiny-glob-0.2.9.tgz",
|
||||
"integrity": "sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"globalyzer": "0.1.0",
|
||||
"globrex": "^0.1.2"
|
||||
}
|
||||
},
|
||||
"tiny-lru": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/tiny-lru/-/tiny-lru-7.0.6.tgz",
|
||||
@ -11321,6 +11634,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"trim-newlines": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-3.0.1.tgz",
|
||||
"integrity": "sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==",
|
||||
"dev": true
|
||||
},
|
||||
"ts-pnp": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-pnp/-/ts-pnp-1.2.0.tgz",
|
||||
|
@ -7,7 +7,8 @@
|
||||
"dev": "next",
|
||||
"test": "jest --coverage",
|
||||
"test:watch": "jest --watchAll",
|
||||
"lint": "next lint"
|
||||
"lint": "next lint",
|
||||
"sync-types": "openapi-typescript https://jsoxsegjhedmsfedvqoh.supabase.co/rest/v1/?apikey=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlhdCI6MTYyNTQxMjUyMCwiZXhwIjoxOTQwOTg4NTIwfQ.ub24E4zDCTkObW7NfIzwd2v0eB6hNYvTwuzYrqMCGIA --output ./src/types/supabase.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@devoxa/paddle-sdk": "0.2.1",
|
||||
@ -77,6 +78,7 @@
|
||||
"isomorphic-fetch": "3.0.0",
|
||||
"jest": "27.0.5",
|
||||
"msw": "0.30.0",
|
||||
"openapi-typescript": "^4.0.2",
|
||||
"postcss": "8.3.5",
|
||||
"react-refresh": "0.10.0",
|
||||
"set-cookie-parser": "2.4.8",
|
||||
|
@ -4,11 +4,13 @@ export enum SmsType {
|
||||
}
|
||||
|
||||
export type Sms = {
|
||||
id: number;
|
||||
id: string;
|
||||
customerId: string;
|
||||
content: string;
|
||||
from: string;
|
||||
to: string;
|
||||
type: SmsType;
|
||||
sentAt: Date;
|
||||
twilioSid?: string;
|
||||
// status: sent/delivered/received
|
||||
sentAt: string; // timestampz
|
||||
};
|
||||
|
@ -1,6 +1,7 @@
|
||||
import appLogger from "../../lib/logger";
|
||||
import supabase from "../supabase/server";
|
||||
import { computeEncryptionKey } from "./_encryption";
|
||||
import { findPhoneNumber } from "./phone-number";
|
||||
|
||||
const logger = appLogger.child({ module: "customer" });
|
||||
|
||||
@ -8,11 +9,12 @@ export type Customer = {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
paddleCustomerId: string;
|
||||
paddleSubscriptionId: string;
|
||||
accountSid: string;
|
||||
authToken: string;
|
||||
encryptionKey: string;
|
||||
accountSid?: string;
|
||||
authToken?: string; // TODO: should encrypt it
|
||||
twimlAppSid?: string;
|
||||
paddleCustomerId?: string;
|
||||
paddleSubscriptionId?: string;
|
||||
};
|
||||
|
||||
type CreateCustomerParams = Pick<Customer, "id" | "email" | "name">;
|
||||
@ -47,6 +49,19 @@ export async function findCustomer(id: Customer["id"]): Promise<Customer> {
|
||||
return data!;
|
||||
}
|
||||
|
||||
export async function findCustomerByPhoneNumber(phoneNumber: string): Promise<Customer> {
|
||||
const { customerId } = await findPhoneNumber(phoneNumber);
|
||||
const { error, data } = await supabase
|
||||
.from<Customer>("customer")
|
||||
.select("*")
|
||||
.eq("id", customerId)
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return data!;
|
||||
}
|
||||
|
||||
export async function updateCustomer(id: string, update: Partial<Customer>) {
|
||||
await supabase.from<Customer>("customer")
|
||||
.update(update)
|
||||
|
@ -30,7 +30,7 @@ export async function createPhoneNumber({
|
||||
return data![0];
|
||||
}
|
||||
|
||||
export async function findPhoneNumber({ id }: Pick<PhoneNumber, "id">): Promise<PhoneNumber> {
|
||||
export async function findPhoneNumberById({ id }: Pick<PhoneNumber, "id">): Promise<PhoneNumber> {
|
||||
const { error, data } = await supabase
|
||||
.from<PhoneNumber>("phone-number")
|
||||
.select("*")
|
||||
@ -42,6 +42,18 @@ export async function findPhoneNumber({ id }: Pick<PhoneNumber, "id">): Promise<
|
||||
return data!;
|
||||
}
|
||||
|
||||
export async function findPhoneNumber(phoneNumber: PhoneNumber["phoneNumber"]): Promise<PhoneNumber> {
|
||||
const { error, data } = await supabase
|
||||
.from<PhoneNumber>("phone-number")
|
||||
.select("*")
|
||||
.eq("phoneNumber", phoneNumber)
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return data!;
|
||||
}
|
||||
|
||||
export async function findCustomerPhoneNumber(customerId: PhoneNumber["customerId"]): Promise<PhoneNumber> {
|
||||
const { error, data } = await supabase
|
||||
.from<PhoneNumber>("phone-number")
|
||||
|
@ -1,10 +1,12 @@
|
||||
import appLogger from "../../lib/logger";
|
||||
import supabase from "../supabase/server";
|
||||
import type { Sms } from "./_types";
|
||||
import { findCustomer } from "./customer";
|
||||
import { decrypt } from "./_encryption";
|
||||
|
||||
const logger = appLogger.child({ module: "sms" });
|
||||
|
||||
export async function insertSms(messages: Omit<Sms, "id">): Promise<Sms> {
|
||||
export async function insertSms(messages: Omit<Sms, "id" | "twilioSid">): Promise<Sms> {
|
||||
const { error, data } = await supabase
|
||||
.from<Sms>("sms")
|
||||
.insert(messages);
|
||||
@ -32,8 +34,28 @@ export async function findCustomerMessages(customerId: Sms["customerId"]): Promi
|
||||
return data!;
|
||||
}
|
||||
|
||||
export async function findCustomerMessageBySid({ customerId, twilioSid }: Pick<Sms, "customerId" | "twilioSid">): Promise<Sms> {
|
||||
const { error, data } = await supabase
|
||||
.from<Sms>("sms")
|
||||
.select("*")
|
||||
.eq("customerId", customerId)
|
||||
.eq("twilioSid", twilioSid)
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return data!;
|
||||
}
|
||||
|
||||
export async function setTwilioSid({ id, twilioSid }: Pick<Sms, "id" | "twilioSid">) {
|
||||
await supabase.from<Sms>("sms")
|
||||
.update({ twilioSid })
|
||||
.eq("id", id)
|
||||
.throwOnError();
|
||||
}
|
||||
|
||||
export async function findConversation(customerId: Sms["customerId"], recipient: Sms["to"]): Promise<Sms[]> {
|
||||
const customer = await findCustomer(customerId);
|
||||
const { error, data } = await supabase
|
||||
.from<Sms>("sms")
|
||||
.select("*")
|
||||
@ -42,5 +64,10 @@ export async function findConversation(customerId: Sms["customerId"], recipient:
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return data!;
|
||||
const conversation = data!.map(message => ({
|
||||
...message,
|
||||
content: decrypt(message.content, customer.encryptionKey),
|
||||
}));
|
||||
|
||||
return conversation;
|
||||
}
|
||||
|
72
src/hooks/use-conversation.ts
Normal file
72
src/hooks/use-conversation.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "react-query";
|
||||
import axios from "axios";
|
||||
import type { Sms } from "../database/_types";
|
||||
import { SmsType } from "../database/_types";
|
||||
import useUser from "./use-user";
|
||||
|
||||
type UseConversationParams = {
|
||||
initialData?: Sms[];
|
||||
recipient: string;
|
||||
}
|
||||
|
||||
export default function useConversation({
|
||||
initialData,
|
||||
recipient,
|
||||
}: UseConversationParams) {
|
||||
const user = useUser();
|
||||
const getConversationUrl = `/api/conversation/${encodeURIComponent(recipient)}`;
|
||||
const fetcher = async () => {
|
||||
const { data } = await axios.get<Sms[]>(getConversationUrl);
|
||||
return data;
|
||||
};
|
||||
const queryClient = useQueryClient();
|
||||
const getConversationQuery = useQuery<Sms[]>(
|
||||
getConversationUrl,
|
||||
fetcher,
|
||||
{
|
||||
initialData,
|
||||
refetchInterval: false,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
);
|
||||
|
||||
const sendMessage = useMutation(
|
||||
(sms: Pick<Sms, "to" | "content">) => axios.post(`/api/conversation/${sms.to}/send-message`, sms, { withCredentials: true }),
|
||||
{
|
||||
onMutate: async (sms: Pick<Sms, "to" | "content">) => {
|
||||
await queryClient.cancelQueries(getConversationUrl);
|
||||
const previousMessages = queryClient.getQueryData<Sms[]>(getConversationUrl);
|
||||
|
||||
if (previousMessages) {
|
||||
queryClient.setQueryData<Sms[]>(getConversationUrl, [
|
||||
...previousMessages,
|
||||
{
|
||||
id: "", // TODO: somehow generate an id
|
||||
from: "", // TODO: get user's phone number
|
||||
customerId: user.userProfile!.id,
|
||||
sentAt: new Date().toISOString(),
|
||||
type: SmsType.SENT,
|
||||
content: sms.content,
|
||||
to: sms.to,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
return { previousMessages };
|
||||
},
|
||||
onError: (error, variables, context) => {
|
||||
if (context?.previousMessages) {
|
||||
queryClient.setQueryData<Sms[]>(getConversationUrl, context.previousMessages);
|
||||
}
|
||||
},
|
||||
onSettled: () => queryClient.invalidateQueries(getConversationUrl),
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
conversation: getConversationQuery.data,
|
||||
error: getConversationQuery.error,
|
||||
refetch: getConversationQuery.refetch,
|
||||
sendMessage,
|
||||
};
|
||||
}
|
54
src/pages/api/conversation/[recipient]/index.ts
Normal file
54
src/pages/api/conversation/[recipient]/index.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import Joi from "joi";
|
||||
|
||||
import { withApiAuthRequired } from "../../../../../lib/session-helpers";
|
||||
import { findConversation } from "../../../../database/sms";
|
||||
import type { ApiError } from "../../_types";
|
||||
import appLogger from "../../../../../lib/logger";
|
||||
|
||||
const logger = appLogger.child({ route: "/api/conversation" });
|
||||
|
||||
type Query = {
|
||||
recipient: string;
|
||||
}
|
||||
|
||||
const querySchema = Joi.object<Query>({
|
||||
recipient: Joi.string().required(),
|
||||
});
|
||||
|
||||
export default withApiAuthRequired(async function getConversationHandler(
|
||||
req,
|
||||
res,
|
||||
user,
|
||||
) {
|
||||
if (req.method !== "GET") {
|
||||
const statusCode = 405;
|
||||
const apiError: ApiError = {
|
||||
statusCode,
|
||||
errorMessage: `Method ${req.method} Not Allowed`,
|
||||
};
|
||||
logger.error(apiError);
|
||||
|
||||
res.setHeader("Allow", ["GET"]);
|
||||
res.status(statusCode).send(apiError);
|
||||
return;
|
||||
}
|
||||
|
||||
const validationResult = querySchema.validate(req.query, { stripUnknown: true });
|
||||
const validationError = validationResult.error;
|
||||
if (validationError) {
|
||||
const statusCode = 400;
|
||||
const apiError: ApiError = {
|
||||
statusCode,
|
||||
errorMessage: "Query is malformed",
|
||||
};
|
||||
logger.error(validationError);
|
||||
|
||||
res.status(statusCode).send(apiError);
|
||||
return;
|
||||
}
|
||||
|
||||
const { recipient }: Query = validationResult.value;
|
||||
const conversation = await findConversation(user.id, recipient);
|
||||
|
||||
return res.status(200).send(conversation);
|
||||
});
|
81
src/pages/api/conversation/[recipient]/send-message.ts
Normal file
81
src/pages/api/conversation/[recipient]/send-message.ts
Normal file
@ -0,0 +1,81 @@
|
||||
import Joi from "joi";
|
||||
|
||||
import { SmsType } from "../../../../database/_types";
|
||||
import { withApiAuthRequired } from "../../../../../lib/session-helpers";
|
||||
import { findConversation, insertSms } from "../../../../database/sms";
|
||||
import type { ApiError } from "../../_types";
|
||||
import appLogger from "../../../../../lib/logger";
|
||||
import { findCustomerPhoneNumber } from "../../../../database/phone-number";
|
||||
import { encrypt } from "../../../../database/_encryption";
|
||||
import { findCustomer } from "../../../../database/customer";
|
||||
import twilio from "twilio";
|
||||
import sendMessageQueue from "../../queue/send-message";
|
||||
|
||||
const logger = appLogger.child({ route: "/api/conversation" });
|
||||
|
||||
type Body = {
|
||||
to: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
const querySchema = Joi.object<Body>({
|
||||
to: Joi.string().required(),
|
||||
content: Joi.string().required(),
|
||||
});
|
||||
|
||||
export default withApiAuthRequired(async function sendMessageHandler(
|
||||
req,
|
||||
res,
|
||||
user,
|
||||
) {
|
||||
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 validationResult = querySchema.validate(req.body, { stripUnknown: true });
|
||||
const validationError = validationResult.error;
|
||||
if (validationError) {
|
||||
const statusCode = 400;
|
||||
const apiError: ApiError = {
|
||||
statusCode,
|
||||
errorMessage: "Body is malformed",
|
||||
};
|
||||
logger.error(validationError);
|
||||
|
||||
res.status(statusCode).send(apiError);
|
||||
return;
|
||||
}
|
||||
|
||||
const customerId = user.id;
|
||||
const customer = await findCustomer(customerId);
|
||||
const { phoneNumber } = await findCustomerPhoneNumber(customerId);
|
||||
const body: Body = validationResult.value;
|
||||
|
||||
const sms = await insertSms({
|
||||
from: phoneNumber,
|
||||
customerId: customerId,
|
||||
sentAt: new Date().toISOString(),
|
||||
type: SmsType.SENT,
|
||||
content: encrypt(body.content, customer.encryptionKey),
|
||||
to: body.to,
|
||||
});
|
||||
await sendMessageQueue.enqueue({
|
||||
id: sms.id,
|
||||
customerId,
|
||||
to: body.to,
|
||||
content: body.content,
|
||||
}, {
|
||||
id: sms.id,
|
||||
});
|
||||
|
||||
return res.status(200).end();
|
||||
});
|
@ -1,4 +1,7 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { insertSms } from "../../database/sms";
|
||||
import { SmsType } from "../../database/_types";
|
||||
import { encrypt } from "../../database/_encryption";
|
||||
import twilio from "twilio";
|
||||
|
||||
export default async function ddd(req: NextApiRequest, res: NextApiResponse) {
|
||||
@ -12,6 +15,34 @@ export default async function ddd(req: NextApiRequest, res: NextApiResponse) {
|
||||
to: phoneNumber,
|
||||
});
|
||||
|
||||
/*const ddd = await insertSms({
|
||||
to: "+213",
|
||||
type: SmsType.SENT,
|
||||
content: encrypt("slt", "4d6d431c9fd1ab7ec620655a793b527bdc4179f0df7fa05dc449d77d90669992"),
|
||||
sentAt: new Date().toISOString(),
|
||||
from: "+33757592025",
|
||||
customerId: "bcb723bc-9706-4811-a964-cc03018bd2ac",
|
||||
});*/
|
||||
|
||||
/*const ddd = await twilio(accountSid, authToken)
|
||||
.applications
|
||||
.create({
|
||||
friendlyName: "Test",
|
||||
smsUrl: "https://phone.mokhtar.dev/api/webhook/incoming-sms",
|
||||
smsMethod: "POST",
|
||||
voiceUrl: "https://phone.mokhtar.dev/api/webhook/incoming-call",
|
||||
voiceMethod: "POST",
|
||||
});*/
|
||||
/*const appSid = "AP0f2fa971567ede86e90faaffb2fa5dc0";
|
||||
const phoneNumberSid = "PNb77c9690c394368bdbaf20ea6fe5e9fc";
|
||||
const ddd = await twilio(accountSid, authToken)
|
||||
.incomingPhoneNumbers
|
||||
.get(phoneNumberSid)
|
||||
.update({
|
||||
smsApplicationSid: appSid,
|
||||
voiceApplicationSid: appSid,
|
||||
});*/
|
||||
|
||||
console.log("ddd", ddd);
|
||||
|
||||
return res.status(200).send(ddd);
|
||||
|
@ -29,6 +29,8 @@ const fetchMessagesQueue = Queue<Payload>(
|
||||
await insertMessagesQueue.enqueue({
|
||||
customerId,
|
||||
messages,
|
||||
}, {
|
||||
id: `insert-messages-${customerId}`,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
@ -24,7 +24,8 @@ const insertMessagesQueue = Queue<Payload>(
|
||||
from: message.from,
|
||||
to: message.to,
|
||||
type: ["received", "receiving"].includes(message.status) ? SmsType.RECEIVED : SmsType.SENT,
|
||||
sentAt: message.dateSent,
|
||||
messageSid: message.sid,
|
||||
sentAt: message.dateSent.toISOString(),
|
||||
}));
|
||||
await insertManySms(sms);
|
||||
},
|
||||
|
34
src/pages/api/queue/send-message.ts
Normal file
34
src/pages/api/queue/send-message.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { Queue } from "quirrel/next";
|
||||
import twilio from "twilio";
|
||||
|
||||
import { findCustomer } from "../../../database/customer";
|
||||
import { findCustomerPhoneNumber } from "../../../database/phone-number";
|
||||
import { setTwilioSid } from "../../../database/sms";
|
||||
|
||||
type Payload = {
|
||||
id: string;
|
||||
customerId: string;
|
||||
to: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
const sendMessageQueue = Queue<Payload>(
|
||||
"api/queue/send-message",
|
||||
async ({ id, customerId, to, content }) => {
|
||||
const customer = await findCustomer(customerId);
|
||||
const { phoneNumber } = await findCustomerPhoneNumber(customerId);
|
||||
const message = await twilio(customer.accountSid, customer.authToken)
|
||||
.messages
|
||||
.create({
|
||||
body: content,
|
||||
to,
|
||||
from: phoneNumber,
|
||||
});
|
||||
await setTwilioSid({ id, twilioSid: message.sid });
|
||||
},
|
||||
{
|
||||
retry: ["1min"],
|
||||
}
|
||||
);
|
||||
|
||||
export default sendMessageQueue;
|
40
src/pages/api/queue/set-twilio-webhooks.ts
Normal file
40
src/pages/api/queue/set-twilio-webhooks.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { Queue } from "quirrel/next";
|
||||
import twilio from "twilio";
|
||||
|
||||
import { findCustomer, updateCustomer } from "../../../database/customer";
|
||||
import { findCustomerPhoneNumber } from "../../../database/phone-number";
|
||||
|
||||
type Payload = {
|
||||
customerId: string;
|
||||
}
|
||||
|
||||
const setTwilioWebhooks = Queue<Payload>(
|
||||
"api/queue/send-message",
|
||||
async ({ customerId }) => {
|
||||
const customer = await findCustomer(customerId);
|
||||
const twimlApp = await twilio(customer.accountSid, customer.authToken)
|
||||
.applications
|
||||
.create({
|
||||
friendlyName: "Virtual Phone",
|
||||
smsUrl: "https://phone.mokhtar.dev/api/webhook/incoming-sms",
|
||||
smsMethod: "POST",
|
||||
voiceUrl: "https://phone.mokhtar.dev/api/webhook/incoming-call",
|
||||
voiceMethod: "POST",
|
||||
});
|
||||
const twimlAppSid = twimlApp.sid;
|
||||
const { phoneNumberSid } = await findCustomerPhoneNumber(customerId);
|
||||
|
||||
await Promise.all([
|
||||
updateCustomer(customerId, { twimlAppSid }),
|
||||
twilio(customer.accountSid, customer.authToken)
|
||||
.incomingPhoneNumbers
|
||||
.get(phoneNumberSid)
|
||||
.update({
|
||||
smsApplicationSid: twimlAppSid,
|
||||
voiceApplicationSid: twimlAppSid,
|
||||
}),
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
export default setTwilioWebhooks;
|
@ -7,6 +7,7 @@ import appLogger from "../../../../lib/logger";
|
||||
import { createPhoneNumber } from "../../../database/phone-number";
|
||||
import { findCustomer } from "../../../database/customer";
|
||||
import fetchMessagesQueue from "../queue/fetch-messages";
|
||||
import setTwilioWebhooks from "../queue/set-twilio-webhooks";
|
||||
|
||||
const logger = appLogger.child({ route: "/api/user/add-phone-number" });
|
||||
|
||||
@ -14,11 +15,11 @@ type Body = {
|
||||
phoneNumberSid: string;
|
||||
}
|
||||
|
||||
export default withApiAuthRequired(async function addPhoneNumberHandler(req, res, user) {
|
||||
const bodySchema = Joi.object<Body>({
|
||||
const bodySchema = Joi.object<Body>({
|
||||
phoneNumberSid: Joi.string().required(),
|
||||
});
|
||||
});
|
||||
|
||||
export default withApiAuthRequired(async function addPhoneNumberHandler(req, res, user) {
|
||||
const validationResult = bodySchema.validate(req.body, { stripUnknown: true });
|
||||
const validationError = validationResult.error;
|
||||
if (validationError) {
|
||||
@ -46,7 +47,10 @@ export default withApiAuthRequired(async function addPhoneNumberHandler(req, res
|
||||
phoneNumber: phoneNumber.phoneNumber,
|
||||
});
|
||||
|
||||
await fetchMessagesQueue.enqueue({ customerId });
|
||||
await Promise.all([
|
||||
fetchMessagesQueue.enqueue({ customerId }, { id: `fetch-messages-${customerId}` }),
|
||||
setTwilioWebhooks.enqueue({ customerId }, { id: `set-twilio-webhooks-${customerId}` }),
|
||||
]);
|
||||
|
||||
return res.status(200).end();
|
||||
});
|
||||
|
5
src/pages/api/webhook/incoming-call.ts
Normal file
5
src/pages/api/webhook/incoming-call.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
export default async function incomingCallHandler(req: NextApiRequest, res: NextApiResponse) {
|
||||
|
||||
}
|
76
src/pages/api/webhook/incoming-sms.ts
Normal file
76
src/pages/api/webhook/incoming-sms.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import twilio from "twilio";
|
||||
|
||||
import type { ApiError } from "../_types";
|
||||
import appLogger from "../../../../lib/logger";
|
||||
import { Customer, findCustomerByPhoneNumber } from "../../../database/customer";
|
||||
import { insertSms } from "../../../database/sms";
|
||||
import { SmsType } from "../../../database/_types";
|
||||
import { encrypt } from "../../../database/_encryption";
|
||||
|
||||
const logger = appLogger.child({ route: "/api/webhook/incoming-sms" });
|
||||
|
||||
export default async function incomingSmsHandler(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 customer = await findCustomerByPhoneNumber(phoneNumber);
|
||||
const url = "https://phone.mokhtar.dev/api/webhook/incoming-sms";
|
||||
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 insertSms({
|
||||
customerId: customer.id,
|
||||
to: req.body.To,
|
||||
from: req.body.From,
|
||||
type: SmsType.RECEIVED,
|
||||
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);
|
||||
}
|
||||
}
|
5
src/pages/api/webhook/outgoing-call.ts
Normal file
5
src/pages/api/webhook/outgoing-call.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
export default async function outgoingCallHandler(req: NextApiRequest, res: NextApiResponse) {
|
||||
|
||||
}
|
@ -50,11 +50,10 @@ const Keypad: NextPage<Props> = () => {
|
||||
<Digit digit="#" />
|
||||
</Row>
|
||||
<Row>
|
||||
<div
|
||||
className="col-start-2 h-12 w-12 flex justify-center items-center mx-auto bg-green-800 rounded-full">
|
||||
<div className="select-none col-start-2 h-12 w-12 flex justify-center items-center mx-auto bg-green-800 rounded-full">
|
||||
<FontAwesomeIcon icon={faPhone} color="white" size="lg" />
|
||||
</div>
|
||||
<div className="my-auto" onClick={pressBackspace}>
|
||||
<div className="select-none my-auto" onClick={pressBackspace}>
|
||||
<FontAwesomeIcon icon={faBackspace} size="lg" />
|
||||
</div>
|
||||
</Row>
|
||||
@ -83,7 +82,7 @@ const Digit: FunctionComponent<{ digit: string }> = ({ children, digit }) => {
|
||||
const onClick = () => pressDigit(digit);
|
||||
|
||||
return (
|
||||
<div onClick={onClick} className="text-3xl cursor-pointer">
|
||||
<div onClick={onClick} className="text-3xl cursor-pointer select-none">
|
||||
{digit}
|
||||
{children}
|
||||
</div>
|
||||
|
@ -1,32 +1,92 @@
|
||||
import { useEffect } from "react";
|
||||
import type { NextPage } from "next";
|
||||
import { useRouter } from "next/router";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faChevronLeft } from "@fortawesome/pro-regular-svg-icons";
|
||||
import clsx from "clsx";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { withPageOnboardingRequired } from "../../../lib/session-helpers";
|
||||
import Layout from "../../components/layout";
|
||||
import useUser from "../../hooks/use-user";
|
||||
import { findConversation } from "../../database/sms";
|
||||
import { decrypt } from "../../database/_encryption";
|
||||
import { findCustomer } from "../../database/customer";
|
||||
import type { Sms } from "../../database/_types";
|
||||
import { SmsType } from "../../database/_types";
|
||||
import supabase from "../../supabase/client";
|
||||
import useUser from "../../hooks/use-user";
|
||||
import useConversation from "../../hooks/use-conversation";
|
||||
import Layout from "../../components/layout";
|
||||
|
||||
type Props = {
|
||||
recipient: string;
|
||||
conversation: Sms[];
|
||||
};
|
||||
}
|
||||
|
||||
const Messages: NextPage<Props> = ({ conversation }) => {
|
||||
type Form = {
|
||||
content: string;
|
||||
}
|
||||
|
||||
const Messages: NextPage<Props> = (props) => {
|
||||
const { userProfile } = useUser();
|
||||
const router = useRouter();
|
||||
const pageTitle = `Messages with ${router.query.recipient}`;
|
||||
const recipient = router.query.recipient as string;
|
||||
const { conversation, error, refetch, sendMessage } = useConversation({
|
||||
initialData: props.conversation,
|
||||
recipient,
|
||||
});
|
||||
const pageTitle = `Messages with ${recipient}`;
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
formState: {
|
||||
isSubmitting,
|
||||
},
|
||||
} = useForm<Form>();
|
||||
|
||||
console.log("userProfile", userProfile);
|
||||
const onSubmit = handleSubmit(async ({ content }) => {
|
||||
if (isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
sendMessage.mutate({
|
||||
to: recipient,
|
||||
content,
|
||||
});
|
||||
setValue("content", "");
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!userProfile) {
|
||||
return;
|
||||
}
|
||||
|
||||
const subscription = supabase
|
||||
.from<Sms>(`sms:customerId=eq.${userProfile.id}`)
|
||||
.on("INSERT", (payload) => {
|
||||
const message = payload.new;
|
||||
if ([message.from, message.to].includes(recipient)) {
|
||||
refetch();
|
||||
}
|
||||
})
|
||||
.subscribe();
|
||||
|
||||
return () => void subscription.unsubscribe();
|
||||
}, [userProfile, recipient, refetch]);
|
||||
|
||||
if (!userProfile) {
|
||||
return <Layout title={pageTitle}>Loading...</Layout>;
|
||||
return (
|
||||
<Layout title={pageTitle}>
|
||||
Loading...
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
console.error("error", error);
|
||||
return (
|
||||
<Layout title={pageTitle}>
|
||||
Oops, something unexpected happened. Please try reloading the page.
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@ -38,7 +98,7 @@ const Messages: NextPage<Props> = ({ conversation }) => {
|
||||
</header>
|
||||
<div className="flex flex-col space-y-6 p-6">
|
||||
<ul>
|
||||
{conversation.map(message => {
|
||||
{conversation!.map(message => {
|
||||
return (
|
||||
<li key={message.id} className={clsx(message.type === SmsType.SENT ? "text-right" : "text-left")}>
|
||||
{message.content}
|
||||
@ -47,6 +107,10 @@ const Messages: NextPage<Props> = ({ conversation }) => {
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
<form onSubmit={onSubmit}>
|
||||
<textarea{...register("content")} />
|
||||
<button type="submit">Send</button>
|
||||
</form>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
@ -63,18 +127,12 @@ export const getServerSideProps = withPageOnboardingRequired<Props>(
|
||||
};
|
||||
}
|
||||
|
||||
const customer = await findCustomer(user.id);
|
||||
const conversation = await findConversation(user.id, recipient);
|
||||
console.log("conversation", conversation);
|
||||
|
||||
console.log("recipient", recipient);
|
||||
return {
|
||||
props: {
|
||||
recipient,
|
||||
conversation: conversation.map(message => ({
|
||||
...message,
|
||||
content: decrypt(message.content, customer.encryptionKey),
|
||||
})),
|
||||
conversation,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
@ -1,14 +1,14 @@
|
||||
import type { InferGetServerSidePropsType, NextPage } from "next";
|
||||
import Link from "next/link";
|
||||
|
||||
import { withPageOnboardingRequired } from "../../lib/session-helpers";
|
||||
import Layout from "../components/layout";
|
||||
import useUser from "../hooks/use-user";
|
||||
import type { Sms } from "../database/_types";
|
||||
import { SmsType } from "../database/_types";
|
||||
import { findCustomerMessages } from "../database/sms";
|
||||
import { findCustomer } from "../database/customer";
|
||||
import { decrypt } from "../database/_encryption";
|
||||
import { withPageOnboardingRequired } from "../../../lib/session-helpers";
|
||||
import type { Sms } from "../../database/_types";
|
||||
import { SmsType } from "../../database/_types";
|
||||
import { findCustomerMessages } from "../../database/sms";
|
||||
import { findCustomer } from "../../database/customer";
|
||||
import { decrypt } from "../../database/_encryption";
|
||||
import useUser from "../../hooks/use-user";
|
||||
import Layout from "../../components/layout";
|
||||
|
||||
type Props = InferGetServerSidePropsType<typeof getServerSideProps>;
|
||||
|
||||
@ -21,21 +21,19 @@ const Messages: NextPage<Props> = ({ conversations }) => {
|
||||
return <Layout title={pageTitle}>Loading...</Layout>;
|
||||
}
|
||||
|
||||
console.log("conversations", conversations);
|
||||
|
||||
return (
|
||||
<Layout title={pageTitle}>
|
||||
<div className="flex flex-col space-y-6 p-6">
|
||||
<p>Messages page</p>
|
||||
<ul>
|
||||
{Object.entries(conversations).map(([recipient, conversation]) => {
|
||||
const lastMessage = conversation[conversation.length - 1];
|
||||
{Object.entries(conversations).map(([recipient, message]) => {
|
||||
return (
|
||||
<li key={recipient}>
|
||||
<Link href={`/messages/${recipient}`}>
|
||||
<a>
|
||||
<div>{recipient}</div>
|
||||
<div>{lastMessage.content}</div>
|
||||
<div>{message.content}</div>
|
||||
<div>{new Date(message.sentAt).toLocaleDateString()}</div>
|
||||
</a>
|
||||
</Link>
|
||||
</li>
|
||||
@ -54,7 +52,9 @@ export const getServerSideProps = withPageOnboardingRequired(
|
||||
async (context, user) => {
|
||||
const customer = await findCustomer(user.id);
|
||||
const messages = await findCustomerMessages(user.id);
|
||||
const conversations = messages.reduce<Conversation>((acc, message) => {
|
||||
|
||||
let conversations: Record<Recipient, Sms> = {};
|
||||
for (const message of messages) {
|
||||
let recipient: string;
|
||||
if (message.type === SmsType.SENT) {
|
||||
recipient = message.to;
|
||||
@ -62,17 +62,19 @@ export const getServerSideProps = withPageOnboardingRequired(
|
||||
recipient = message.from;
|
||||
}
|
||||
|
||||
if (!acc[recipient]) {
|
||||
acc[recipient] = [];
|
||||
}
|
||||
|
||||
acc[recipient].push({
|
||||
if (
|
||||
!conversations[recipient] ||
|
||||
message.sentAt > conversations[recipient].sentAt
|
||||
) {
|
||||
conversations[recipient] = {
|
||||
...message,
|
||||
content: decrypt(message.content, customer.encryptionKey), // TODO: should probably decrypt on the phone
|
||||
});
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
};
|
||||
}
|
||||
}
|
||||
conversations = Object.fromEntries(
|
||||
Object.entries(conversations).sort(([,a], [,b]) => b.sentAt.localeCompare(a.sentAt))
|
||||
);
|
||||
|
||||
return {
|
||||
props: { conversations },
|
@ -11,18 +11,20 @@ type Props = InferGetServerSidePropsType<typeof getServerSideProps>;
|
||||
|
||||
const logger = appLogger.child({ page: "/account/settings" });
|
||||
|
||||
/* eslint-disable react/display-name */
|
||||
const navigation = [
|
||||
{
|
||||
name: "Account",
|
||||
href: "/settings/account",
|
||||
icon: ({className = "w-8 h-8"}) => <FontAwesomeIcon size="lg" className={className} icon={faUserCircle} />
|
||||
icon: ({ className = "w-8 h-8" }) => <FontAwesomeIcon size="lg" className={className} icon={faUserCircle} />,
|
||||
},
|
||||
{
|
||||
name: "Billing",
|
||||
href: "/settings/billing",
|
||||
icon: ({className = "w-8 h-8"}) => <FontAwesomeIcon size="lg" className={className} icon={faCreditCard} />
|
||||
icon: ({ className = "w-8 h-8" }) => <FontAwesomeIcon size="lg" className={className} icon={faCreditCard} />,
|
||||
},
|
||||
];
|
||||
/* eslint-enable react/display-name */
|
||||
|
||||
const Settings: NextPage<Props> = (props) => {
|
||||
return (
|
||||
@ -34,9 +36,9 @@ const Settings: NextPage<Props> = (props) => {
|
||||
<a
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className='border-transparent text-gray-900 hover:bg-gray-50 hover:text-gray-900 group border-l-4 px-3 py-2 flex items-center text-sm font-medium'
|
||||
className="border-transparent text-gray-900 hover:bg-gray-50 hover:text-gray-900 group border-l-4 px-3 py-2 flex items-center text-sm font-medium"
|
||||
>
|
||||
<item.icon className='text-gray-400 group-hover:text-gray-500 flex-shrink-0 -ml-1 mr-3 h-6 w-6' />
|
||||
<item.icon className="text-gray-400 group-hover:text-gray-500 flex-shrink-0 -ml-1 mr-3 h-6 w-6" />
|
||||
<span className="truncate">{item.name}</span>
|
||||
</a>
|
||||
))}
|
||||
|
@ -46,7 +46,7 @@ const StepThree: NextPage<Props> = ({ hasTwilioCredentials, availablePhoneNumber
|
||||
previous={{ href: "/welcome/step-two", label: "Back" }}
|
||||
>
|
||||
<div className="flex flex-col space-y-4 items-center">
|
||||
<span>You don't have any phone number, fill your Twilio credentials first</span>
|
||||
<span>You don't have any phone number, fill your Twilio credentials first</span>
|
||||
</div>
|
||||
</OnboardingLayout>
|
||||
)
|
||||
@ -90,7 +90,7 @@ const StepThree: NextPage<Props> = ({ hasTwilioCredentials, availablePhoneNumber
|
||||
|
||||
export const getServerSideProps = withPageAuthRequired(async (context, user) => {
|
||||
const customer = await findCustomer(user.id);
|
||||
const hasTwilioCredentials = customer.accountSid.length > 0 && customer.authToken.length > 0;
|
||||
const hasTwilioCredentials = customer.accountSid?.length && customer.authToken?.length;
|
||||
const incomingPhoneNumbers = await twilio(customer.accountSid, customer.authToken)
|
||||
.incomingPhoneNumbers
|
||||
.list();
|
||||
|
Loading…
Reference in New Issue
Block a user