list conversations

send sms
begin webhooks
This commit is contained in:
m5r 2021-07-21 00:28:56 +08:00
parent 3762305c4f
commit 6a12d0cd93
25 changed files with 915 additions and 86 deletions

3
.eslintrc Normal file
View File

@ -0,0 +1,3 @@
{
"extends": "next"
}

View File

@ -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
View File

@ -2065,11 +2065,23 @@
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz",
"integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==" "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": { "@types/node": {
"version": "15.12.4", "version": "15.12.4",
"resolved": "https://registry.npmjs.org/@types/node/-/node-15.12.4.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-15.12.4.tgz",
"integrity": "sha512-zrNj1+yqYF4WskCMOHwN+w9iuD12+dGm0rQ35HLl9/Ouuq52cEtd0CH9qMgrdNmi5ejC1/V7vKEXYubB+65DkA==" "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": { "@types/on-headers": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@types/on-headers/-/on-headers-1.0.0.tgz", "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==", "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
"dev": true "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": { "caniuse-lite": {
"version": "1.0.30001242", "version": "1.0.30001242",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001242.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001242.tgz",
@ -4005,6 +4042,30 @@
"ms": "2.1.2" "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": { "decimal.js": {
"version": "10.3.1", "version": "10.3.1",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.3.1.tgz", "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", "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz",
"integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==" "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": { "globby": {
"version": "11.0.4", "version": "11.0.4",
"resolved": "https://registry.npmjs.org/globby/-/globby-11.0.4.tgz", "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": { "google-auth-library": {
"version": "7.2.0", "version": "7.2.0",
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-7.2.0.tgz", "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-7.2.0.tgz",
@ -5846,6 +5919,12 @@
"jws": "^4.0.0" "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": { "has": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
@ -6369,6 +6448,12 @@
"integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==",
"optional": true "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": { "is-potential-custom-element-name": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", "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" "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": { "kleur": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
@ -8298,6 +8389,12 @@
"tmpl": "1.0.x" "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": { "match-sorter": {
"version": "6.3.0", "version": "6.3.0",
"resolved": "https://registry.npmjs.org/match-sorter/-/match-sorter-6.3.0.tgz", "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", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=" "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": { "merge-stream": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
@ -8380,8 +8610,7 @@
"mime": { "mime": {
"version": "2.5.2", "version": "2.5.2",
"resolved": "https://registry.npmjs.org/mime/-/mime-2.5.2.tgz", "resolved": "https://registry.npmjs.org/mime/-/mime-2.5.2.tgz",
"integrity": "sha512-tqkh47FzKeCPD2PUiPB6pkbMzsCasjxAfC62/Wap5qrUWcb+sFasXUC5I3gYM5iBM8v/Qpn4UK0x+j0iHyFPDg==", "integrity": "sha512-tqkh47FzKeCPD2PUiPB6pkbMzsCasjxAfC62/Wap5qrUWcb+sFasXUC5I3gYM5iBM8v/Qpn4UK0x+j0iHyFPDg=="
"optional": true
}, },
"mime-db": { "mime-db": {
"version": "1.48.0", "version": "1.48.0",
@ -8441,6 +8670,25 @@
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz",
"integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==" "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": { "mkdirp": {
"version": "0.5.5", "version": "0.5.5",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz", "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", "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-9.1.0.tgz",
"integrity": "sha512-mhXh8QN8sbErlxfxBeZ/pzgvmDn443p8CXlxwGSi2bWANZAFvjLPI0PoGjqHW+JdBbXg6uvmvM81WXaweh/SVA==" "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": { "openid-client": {
"version": "4.7.4", "version": "4.7.4",
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-4.7.4.tgz", "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-4.7.4.tgz",
@ -9598,6 +9895,12 @@
"integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
"dev": true "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": { "pretty-format": {
"version": "27.0.6", "version": "27.0.6",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.0.6.tgz", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.0.6.tgz",
@ -11245,6 +11548,16 @@
"setimmediate": "^1.0.4" "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": { "tiny-lru": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/tiny-lru/-/tiny-lru-7.0.6.tgz", "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": { "ts-pnp": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/ts-pnp/-/ts-pnp-1.2.0.tgz", "resolved": "https://registry.npmjs.org/ts-pnp/-/ts-pnp-1.2.0.tgz",

View File

@ -7,7 +7,8 @@
"dev": "next", "dev": "next",
"test": "jest --coverage", "test": "jest --coverage",
"test:watch": "jest --watchAll", "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": { "dependencies": {
"@devoxa/paddle-sdk": "0.2.1", "@devoxa/paddle-sdk": "0.2.1",
@ -77,6 +78,7 @@
"isomorphic-fetch": "3.0.0", "isomorphic-fetch": "3.0.0",
"jest": "27.0.5", "jest": "27.0.5",
"msw": "0.30.0", "msw": "0.30.0",
"openapi-typescript": "^4.0.2",
"postcss": "8.3.5", "postcss": "8.3.5",
"react-refresh": "0.10.0", "react-refresh": "0.10.0",
"set-cookie-parser": "2.4.8", "set-cookie-parser": "2.4.8",

View File

@ -4,11 +4,13 @@ export enum SmsType {
} }
export type Sms = { export type Sms = {
id: number; id: string;
customerId: string; customerId: string;
content: string; content: string;
from: string; from: string;
to: string; to: string;
type: SmsType; type: SmsType;
sentAt: Date; twilioSid?: string;
// status: sent/delivered/received
sentAt: string; // timestampz
}; };

View File

@ -1,6 +1,7 @@
import appLogger from "../../lib/logger"; import appLogger from "../../lib/logger";
import supabase from "../supabase/server"; import supabase from "../supabase/server";
import { computeEncryptionKey } from "./_encryption"; import { computeEncryptionKey } from "./_encryption";
import { findPhoneNumber } from "./phone-number";
const logger = appLogger.child({ module: "customer" }); const logger = appLogger.child({ module: "customer" });
@ -8,11 +9,12 @@ export type Customer = {
id: string; id: string;
email: string; email: string;
name: string; name: string;
paddleCustomerId: string;
paddleSubscriptionId: string;
accountSid: string;
authToken: string;
encryptionKey: string; encryptionKey: string;
accountSid?: string;
authToken?: string; // TODO: should encrypt it
twimlAppSid?: string;
paddleCustomerId?: string;
paddleSubscriptionId?: string;
}; };
type CreateCustomerParams = Pick<Customer, "id" | "email" | "name">; type CreateCustomerParams = Pick<Customer, "id" | "email" | "name">;
@ -47,6 +49,19 @@ export async function findCustomer(id: Customer["id"]): Promise<Customer> {
return data!; 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>) { export async function updateCustomer(id: string, update: Partial<Customer>) {
await supabase.from<Customer>("customer") await supabase.from<Customer>("customer")
.update(update) .update(update)

View File

@ -30,7 +30,7 @@ export async function createPhoneNumber({
return data![0]; 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 const { error, data } = await supabase
.from<PhoneNumber>("phone-number") .from<PhoneNumber>("phone-number")
.select("*") .select("*")
@ -42,6 +42,18 @@ export async function findPhoneNumber({ id }: Pick<PhoneNumber, "id">): Promise<
return data!; 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> { export async function findCustomerPhoneNumber(customerId: PhoneNumber["customerId"]): Promise<PhoneNumber> {
const { error, data } = await supabase const { error, data } = await supabase
.from<PhoneNumber>("phone-number") .from<PhoneNumber>("phone-number")

View File

@ -1,10 +1,12 @@
import appLogger from "../../lib/logger"; import appLogger from "../../lib/logger";
import supabase from "../supabase/server"; import supabase from "../supabase/server";
import type { Sms } from "./_types"; import type { Sms } from "./_types";
import { findCustomer } from "./customer";
import { decrypt } from "./_encryption";
const logger = appLogger.child({ module: "sms" }); 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 const { error, data } = await supabase
.from<Sms>("sms") .from<Sms>("sms")
.insert(messages); .insert(messages);
@ -32,8 +34,28 @@ export async function findCustomerMessages(customerId: Sms["customerId"]): Promi
return data!; 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[]> { export async function findConversation(customerId: Sms["customerId"], recipient: Sms["to"]): Promise<Sms[]> {
const customer = await findCustomer(customerId);
const { error, data } = await supabase const { error, data } = await supabase
.from<Sms>("sms") .from<Sms>("sms")
.select("*") .select("*")
@ -42,5 +64,10 @@ export async function findConversation(customerId: Sms["customerId"], recipient:
if (error) throw error; if (error) throw error;
return data!; const conversation = data!.map(message => ({
...message,
content: decrypt(message.content, customer.encryptionKey),
}));
return conversation;
} }

View 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,
};
}

View 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);
});

View 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();
});

View File

@ -1,4 +1,7 @@
import type { NextApiRequest, NextApiResponse } from "next"; 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"; import twilio from "twilio";
export default async function ddd(req: NextApiRequest, res: NextApiResponse) { export default async function ddd(req: NextApiRequest, res: NextApiResponse) {
@ -12,6 +15,34 @@ export default async function ddd(req: NextApiRequest, res: NextApiResponse) {
to: phoneNumber, 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); console.log("ddd", ddd);
return res.status(200).send(ddd); return res.status(200).send(ddd);

View File

@ -29,6 +29,8 @@ const fetchMessagesQueue = Queue<Payload>(
await insertMessagesQueue.enqueue({ await insertMessagesQueue.enqueue({
customerId, customerId,
messages, messages,
}, {
id: `insert-messages-${customerId}`,
}); });
}, },
); );

View File

@ -24,7 +24,8 @@ const insertMessagesQueue = Queue<Payload>(
from: message.from, from: message.from,
to: message.to, to: message.to,
type: ["received", "receiving"].includes(message.status) ? SmsType.RECEIVED : SmsType.SENT, type: ["received", "receiving"].includes(message.status) ? SmsType.RECEIVED : SmsType.SENT,
sentAt: message.dateSent, messageSid: message.sid,
sentAt: message.dateSent.toISOString(),
})); }));
await insertManySms(sms); await insertManySms(sms);
}, },

View 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;

View 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;

View File

@ -7,6 +7,7 @@ import appLogger from "../../../../lib/logger";
import { createPhoneNumber } from "../../../database/phone-number"; import { createPhoneNumber } from "../../../database/phone-number";
import { findCustomer } from "../../../database/customer"; import { findCustomer } from "../../../database/customer";
import fetchMessagesQueue from "../queue/fetch-messages"; import fetchMessagesQueue from "../queue/fetch-messages";
import setTwilioWebhooks from "../queue/set-twilio-webhooks";
const logger = appLogger.child({ route: "/api/user/add-phone-number" }); const logger = appLogger.child({ route: "/api/user/add-phone-number" });
@ -14,11 +15,11 @@ type Body = {
phoneNumberSid: string; 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(),
phoneNumberSid: Joi.string().required(), });
});
export default withApiAuthRequired(async function addPhoneNumberHandler(req, res, user) {
const validationResult = bodySchema.validate(req.body, { stripUnknown: true }); const validationResult = bodySchema.validate(req.body, { stripUnknown: true });
const validationError = validationResult.error; const validationError = validationResult.error;
if (validationError) { if (validationError) {
@ -46,7 +47,10 @@ export default withApiAuthRequired(async function addPhoneNumberHandler(req, res
phoneNumber: phoneNumber.phoneNumber, 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(); return res.status(200).end();
}); });

View File

@ -0,0 +1,5 @@
import type { NextApiRequest, NextApiResponse } from "next";
export default async function incomingCallHandler(req: NextApiRequest, res: NextApiResponse) {
}

View 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);
}
}

View File

@ -0,0 +1,5 @@
import type { NextApiRequest, NextApiResponse } from "next";
export default async function outgoingCallHandler(req: NextApiRequest, res: NextApiResponse) {
}

View File

@ -50,11 +50,10 @@ const Keypad: NextPage<Props> = () => {
<Digit digit="#" /> <Digit digit="#" />
</Row> </Row>
<Row> <Row>
<div <div className="select-none col-start-2 h-12 w-12 flex justify-center items-center mx-auto bg-green-800 rounded-full">
className="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" /> <FontAwesomeIcon icon={faPhone} color="white" size="lg" />
</div> </div>
<div className="my-auto" onClick={pressBackspace}> <div className="select-none my-auto" onClick={pressBackspace}>
<FontAwesomeIcon icon={faBackspace} size="lg" /> <FontAwesomeIcon icon={faBackspace} size="lg" />
</div> </div>
</Row> </Row>
@ -83,7 +82,7 @@ const Digit: FunctionComponent<{ digit: string }> = ({ children, digit }) => {
const onClick = () => pressDigit(digit); const onClick = () => pressDigit(digit);
return ( return (
<div onClick={onClick} className="text-3xl cursor-pointer"> <div onClick={onClick} className="text-3xl cursor-pointer select-none">
{digit} {digit}
{children} {children}
</div> </div>

View File

@ -1,32 +1,92 @@
import { useEffect } from "react";
import type { NextPage } from "next"; import type { NextPage } from "next";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faChevronLeft } from "@fortawesome/pro-regular-svg-icons"; import { faChevronLeft } from "@fortawesome/pro-regular-svg-icons";
import clsx from "clsx"; import clsx from "clsx";
import { useForm } from "react-hook-form";
import { withPageOnboardingRequired } from "../../../lib/session-helpers"; import { withPageOnboardingRequired } from "../../../lib/session-helpers";
import Layout from "../../components/layout";
import useUser from "../../hooks/use-user";
import { findConversation } from "../../database/sms"; import { findConversation } from "../../database/sms";
import { decrypt } from "../../database/_encryption";
import { findCustomer } from "../../database/customer";
import type { Sms } from "../../database/_types"; import type { Sms } from "../../database/_types";
import { SmsType } 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 = { type Props = {
recipient: string; recipient: string;
conversation: Sms[]; conversation: Sms[];
}; }
const Messages: NextPage<Props> = ({ conversation }) => { type Form = {
content: string;
}
const Messages: NextPage<Props> = (props) => {
const { userProfile } = useUser(); const { userProfile } = useUser();
const router = useRouter(); 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) { 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 ( return (
@ -38,7 +98,7 @@ const Messages: NextPage<Props> = ({ conversation }) => {
</header> </header>
<div className="flex flex-col space-y-6 p-6"> <div className="flex flex-col space-y-6 p-6">
<ul> <ul>
{conversation.map(message => { {conversation!.map(message => {
return ( return (
<li key={message.id} className={clsx(message.type === SmsType.SENT ? "text-right" : "text-left")}> <li key={message.id} className={clsx(message.type === SmsType.SENT ? "text-right" : "text-left")}>
{message.content} {message.content}
@ -47,6 +107,10 @@ const Messages: NextPage<Props> = ({ conversation }) => {
})} })}
</ul> </ul>
</div> </div>
<form onSubmit={onSubmit}>
<textarea{...register("content")} />
<button type="submit">Send</button>
</form>
</Layout> </Layout>
); );
}; };
@ -63,18 +127,12 @@ export const getServerSideProps = withPageOnboardingRequired<Props>(
}; };
} }
const customer = await findCustomer(user.id);
const conversation = await findConversation(user.id, recipient); const conversation = await findConversation(user.id, recipient);
console.log("conversation", conversation);
console.log("recipient", recipient);
return { return {
props: { props: {
recipient, recipient,
conversation: conversation.map(message => ({ conversation,
...message,
content: decrypt(message.content, customer.encryptionKey),
})),
}, },
}; };
}, },

View File

@ -1,14 +1,14 @@
import type { InferGetServerSidePropsType, NextPage } from "next"; import type { InferGetServerSidePropsType, NextPage } from "next";
import Link from "next/link"; import Link from "next/link";
import { withPageOnboardingRequired } from "../../lib/session-helpers"; import { withPageOnboardingRequired } from "../../../lib/session-helpers";
import Layout from "../components/layout"; import type { Sms } from "../../database/_types";
import useUser from "../hooks/use-user"; import { SmsType } from "../../database/_types";
import type { Sms } from "../database/_types"; import { findCustomerMessages } from "../../database/sms";
import { SmsType } from "../database/_types"; import { findCustomer } from "../../database/customer";
import { findCustomerMessages } from "../database/sms"; import { decrypt } from "../../database/_encryption";
import { findCustomer } from "../database/customer"; import useUser from "../../hooks/use-user";
import { decrypt } from "../database/_encryption"; import Layout from "../../components/layout";
type Props = InferGetServerSidePropsType<typeof getServerSideProps>; type Props = InferGetServerSidePropsType<typeof getServerSideProps>;
@ -21,21 +21,19 @@ const Messages: NextPage<Props> = ({ conversations }) => {
return <Layout title={pageTitle}>Loading...</Layout>; return <Layout title={pageTitle}>Loading...</Layout>;
} }
console.log("conversations", conversations);
return ( return (
<Layout title={pageTitle}> <Layout title={pageTitle}>
<div className="flex flex-col space-y-6 p-6"> <div className="flex flex-col space-y-6 p-6">
<p>Messages page</p> <p>Messages page</p>
<ul> <ul>
{Object.entries(conversations).map(([recipient, conversation]) => { {Object.entries(conversations).map(([recipient, message]) => {
const lastMessage = conversation[conversation.length - 1];
return ( return (
<li key={recipient}> <li key={recipient}>
<Link href={`/messages/${recipient}`}> <Link href={`/messages/${recipient}`}>
<a> <a>
<div>{recipient}</div> <div>{recipient}</div>
<div>{lastMessage.content}</div> <div>{message.content}</div>
<div>{new Date(message.sentAt).toLocaleDateString()}</div>
</a> </a>
</Link> </Link>
</li> </li>
@ -54,7 +52,9 @@ export const getServerSideProps = withPageOnboardingRequired(
async (context, user) => { async (context, user) => {
const customer = await findCustomer(user.id); const customer = await findCustomer(user.id);
const messages = await findCustomerMessages(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; let recipient: string;
if (message.type === SmsType.SENT) { if (message.type === SmsType.SENT) {
recipient = message.to; recipient = message.to;
@ -62,17 +62,19 @@ export const getServerSideProps = withPageOnboardingRequired(
recipient = message.from; recipient = message.from;
} }
if (!acc[recipient]) { if (
acc[recipient] = []; !conversations[recipient] ||
message.sentAt > conversations[recipient].sentAt
) {
conversations[recipient] = {
...message,
content: decrypt(message.content, customer.encryptionKey), // TODO: should probably decrypt on the phone
};
} }
}
acc[recipient].push({ conversations = Object.fromEntries(
...message, Object.entries(conversations).sort(([,a], [,b]) => b.sentAt.localeCompare(a.sentAt))
content: decrypt(message.content, customer.encryptionKey), // TODO: should probably decrypt on the phone );
});
return acc;
}, {});
return { return {
props: { conversations }, props: { conversations },

View File

@ -11,18 +11,20 @@ type Props = InferGetServerSidePropsType<typeof getServerSideProps>;
const logger = appLogger.child({ page: "/account/settings" }); const logger = appLogger.child({ page: "/account/settings" });
/* eslint-disable react/display-name */
const navigation = [ const navigation = [
{ {
name: "Account", name: "Account",
href: "/settings/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", name: "Billing",
href: "/settings/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) => { const Settings: NextPage<Props> = (props) => {
return ( return (
@ -34,9 +36,9 @@ const Settings: NextPage<Props> = (props) => {
<a <a
key={item.name} key={item.name}
href={item.href} 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> <span className="truncate">{item.name}</span>
</a> </a>
))} ))}

View File

@ -46,7 +46,7 @@ const StepThree: NextPage<Props> = ({ hasTwilioCredentials, availablePhoneNumber
previous={{ href: "/welcome/step-two", label: "Back" }} previous={{ href: "/welcome/step-two", label: "Back" }}
> >
<div className="flex flex-col space-y-4 items-center"> <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&#39;t have any phone number, fill your Twilio credentials first</span>
</div> </div>
</OnboardingLayout> </OnboardingLayout>
) )
@ -90,7 +90,7 @@ const StepThree: NextPage<Props> = ({ hasTwilioCredentials, availablePhoneNumber
export const getServerSideProps = withPageAuthRequired(async (context, user) => { export const getServerSideProps = withPageAuthRequired(async (context, user) => {
const customer = await findCustomer(user.id); 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) const incomingPhoneNumbers = await twilio(customer.accountSid, customer.authToken)
.incomingPhoneNumbers .incomingPhoneNumbers
.list(); .list();