diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..15b1ed9 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,3 @@ +{ + "extends": "next" +} diff --git a/api/docker-compose.yml b/api/docker-compose.yml deleted file mode 100644 index 88b8868..0000000 --- a/api/docker-compose.yml +++ /dev/null @@ -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 \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index aace524..17ed0fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 19a22ac..7a8407a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/database/_types.ts b/src/database/_types.ts index 14d4569..5a3ee66 100644 --- a/src/database/_types.ts +++ b/src/database/_types.ts @@ -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 }; diff --git a/src/database/customer.ts b/src/database/customer.ts index aedf545..f44e8a8 100644 --- a/src/database/customer.ts +++ b/src/database/customer.ts @@ -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; @@ -47,6 +49,19 @@ export async function findCustomer(id: Customer["id"]): Promise { return data!; } +export async function findCustomerByPhoneNumber(phoneNumber: string): Promise { + const { customerId } = await findPhoneNumber(phoneNumber); + const { error, data } = await supabase + .from("customer") + .select("*") + .eq("id", customerId) + .single(); + + if (error) throw error; + + return data!; +} + export async function updateCustomer(id: string, update: Partial) { await supabase.from("customer") .update(update) diff --git a/src/database/phone-number.ts b/src/database/phone-number.ts index 31b19ff..3d3a274 100644 --- a/src/database/phone-number.ts +++ b/src/database/phone-number.ts @@ -30,7 +30,7 @@ export async function createPhoneNumber({ return data![0]; } -export async function findPhoneNumber({ id }: Pick): Promise { +export async function findPhoneNumberById({ id }: Pick): Promise { const { error, data } = await supabase .from("phone-number") .select("*") @@ -42,6 +42,18 @@ export async function findPhoneNumber({ id }: Pick): Promise< return data!; } +export async function findPhoneNumber(phoneNumber: PhoneNumber["phoneNumber"]): Promise { + const { error, data } = await supabase + .from("phone-number") + .select("*") + .eq("phoneNumber", phoneNumber) + .single(); + + if (error) throw error; + + return data!; +} + export async function findCustomerPhoneNumber(customerId: PhoneNumber["customerId"]): Promise { const { error, data } = await supabase .from("phone-number") diff --git a/src/database/sms.ts b/src/database/sms.ts index 70142d3..90da669 100644 --- a/src/database/sms.ts +++ b/src/database/sms.ts @@ -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): Promise { +export async function insertSms(messages: Omit): Promise { const { error, data } = await supabase .from("sms") .insert(messages); @@ -32,8 +34,28 @@ export async function findCustomerMessages(customerId: Sms["customerId"]): Promi return data!; } +export async function findCustomerMessageBySid({ customerId, twilioSid }: Pick): Promise { + const { error, data } = await supabase + .from("sms") + .select("*") + .eq("customerId", customerId) + .eq("twilioSid", twilioSid) + .single(); + + if (error) throw error; + + return data!; +} + +export async function setTwilioSid({ id, twilioSid }: Pick) { + await supabase.from("sms") + .update({ twilioSid }) + .eq("id", id) + .throwOnError(); +} export async function findConversation(customerId: Sms["customerId"], recipient: Sms["to"]): Promise { + const customer = await findCustomer(customerId); const { error, data } = await supabase .from("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; } diff --git a/src/hooks/use-conversation.ts b/src/hooks/use-conversation.ts new file mode 100644 index 0000000..851d53f --- /dev/null +++ b/src/hooks/use-conversation.ts @@ -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(getConversationUrl); + return data; + }; + const queryClient = useQueryClient(); + const getConversationQuery = useQuery( + getConversationUrl, + fetcher, + { + initialData, + refetchInterval: false, + refetchOnWindowFocus: false, + }, + ); + + const sendMessage = useMutation( + (sms: Pick) => axios.post(`/api/conversation/${sms.to}/send-message`, sms, { withCredentials: true }), + { + onMutate: async (sms: Pick) => { + await queryClient.cancelQueries(getConversationUrl); + const previousMessages = queryClient.getQueryData(getConversationUrl); + + if (previousMessages) { + queryClient.setQueryData(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(getConversationUrl, context.previousMessages); + } + }, + onSettled: () => queryClient.invalidateQueries(getConversationUrl), + }, + ); + + return { + conversation: getConversationQuery.data, + error: getConversationQuery.error, + refetch: getConversationQuery.refetch, + sendMessage, + }; +} diff --git a/src/pages/api/conversation/[recipient]/index.ts b/src/pages/api/conversation/[recipient]/index.ts new file mode 100644 index 0000000..695347a --- /dev/null +++ b/src/pages/api/conversation/[recipient]/index.ts @@ -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({ + 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); +}); diff --git a/src/pages/api/conversation/[recipient]/send-message.ts b/src/pages/api/conversation/[recipient]/send-message.ts new file mode 100644 index 0000000..459b323 --- /dev/null +++ b/src/pages/api/conversation/[recipient]/send-message.ts @@ -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({ + 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(); +}); diff --git a/src/pages/api/ddd.ts b/src/pages/api/ddd.ts index 9f5f28e..cde4e55 100644 --- a/src/pages/api/ddd.ts +++ b/src/pages/api/ddd.ts @@ -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); diff --git a/src/pages/api/queue/fetch-messages.ts b/src/pages/api/queue/fetch-messages.ts index 64f74ec..41cdeec 100644 --- a/src/pages/api/queue/fetch-messages.ts +++ b/src/pages/api/queue/fetch-messages.ts @@ -29,6 +29,8 @@ const fetchMessagesQueue = Queue( await insertMessagesQueue.enqueue({ customerId, messages, + }, { + id: `insert-messages-${customerId}`, }); }, ); diff --git a/src/pages/api/queue/insert-messages.ts b/src/pages/api/queue/insert-messages.ts index 0aadb9a..824a0b1 100644 --- a/src/pages/api/queue/insert-messages.ts +++ b/src/pages/api/queue/insert-messages.ts @@ -24,7 +24,8 @@ const insertMessagesQueue = Queue( 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); }, diff --git a/src/pages/api/queue/send-message.ts b/src/pages/api/queue/send-message.ts new file mode 100644 index 0000000..802ddda --- /dev/null +++ b/src/pages/api/queue/send-message.ts @@ -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( + "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; diff --git a/src/pages/api/queue/set-twilio-webhooks.ts b/src/pages/api/queue/set-twilio-webhooks.ts new file mode 100644 index 0000000..e8fb17f --- /dev/null +++ b/src/pages/api/queue/set-twilio-webhooks.ts @@ -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( + "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; diff --git a/src/pages/api/user/add-phone-number.ts b/src/pages/api/user/add-phone-number.ts index 658641a..e70f57b 100644 --- a/src/pages/api/user/add-phone-number.ts +++ b/src/pages/api/user/add-phone-number.ts @@ -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({ - phoneNumberSid: Joi.string().required(), - }); +const bodySchema = Joi.object({ + 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(); }); diff --git a/src/pages/api/webhook/incoming-call.ts b/src/pages/api/webhook/incoming-call.ts new file mode 100644 index 0000000..b39328a --- /dev/null +++ b/src/pages/api/webhook/incoming-call.ts @@ -0,0 +1,5 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +export default async function incomingCallHandler(req: NextApiRequest, res: NextApiResponse) { + +} diff --git a/src/pages/api/webhook/incoming-sms.ts b/src/pages/api/webhook/incoming-sms.ts new file mode 100644 index 0000000..2e842f0 --- /dev/null +++ b/src/pages/api/webhook/incoming-sms.ts @@ -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); + } +} diff --git a/src/pages/api/webhook/outgoing-call.ts b/src/pages/api/webhook/outgoing-call.ts new file mode 100644 index 0000000..2abd539 --- /dev/null +++ b/src/pages/api/webhook/outgoing-call.ts @@ -0,0 +1,5 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +export default async function outgoingCallHandler(req: NextApiRequest, res: NextApiResponse) { + +} diff --git a/src/pages/keypad.tsx b/src/pages/keypad.tsx index 6449993..4c8e2e8 100644 --- a/src/pages/keypad.tsx +++ b/src/pages/keypad.tsx @@ -50,11 +50,10 @@ const Keypad: NextPage = () => { -
+
-
+
@@ -83,7 +82,7 @@ const Digit: FunctionComponent<{ digit: string }> = ({ children, digit }) => { const onClick = () => pressDigit(digit); return ( -
+
{digit} {children}
diff --git a/src/pages/messages/[recipient].tsx b/src/pages/messages/[recipient].tsx index 19d775e..6390aeb 100644 --- a/src/pages/messages/[recipient].tsx +++ b/src/pages/messages/[recipient].tsx @@ -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 = ({ conversation }) => { +type Form = { + content: string; +} + +const Messages: NextPage = (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
(); - 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: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 Loading...; + return ( + + Loading... + + ); + } + + if (error) { + console.error("error", error); + return ( + + Oops, something unexpected happened. Please try reloading the page. + + ); } return ( @@ -38,7 +98,7 @@ const Messages: NextPage = ({ conversation }) => {
    - {conversation.map(message => { + {conversation!.map(message => { return (
  • {message.content} @@ -47,6 +107,10 @@ const Messages: NextPage = ({ conversation }) => { })}
+ + + + ); }; @@ -63,18 +127,12 @@ export const getServerSideProps = withPageOnboardingRequired( }; } - 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, }, }; }, diff --git a/src/pages/messages.tsx b/src/pages/messages/index.tsx similarity index 52% rename from src/pages/messages.tsx rename to src/pages/messages/index.tsx index 281a8fe..6cd703a 100644 --- a/src/pages/messages.tsx +++ b/src/pages/messages/index.tsx @@ -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; @@ -21,21 +21,19 @@ const Messages: NextPage = ({ conversations }) => { return Loading...; } - console.log("conversations", conversations); - return (

Messages page

    - {Object.entries(conversations).map(([recipient, conversation]) => { - const lastMessage = conversation[conversation.length - 1]; + {Object.entries(conversations).map(([recipient, message]) => { return (
  • {recipient}
    -
    {lastMessage.content}
    +
    {message.content}
    +
    {new Date(message.sentAt).toLocaleDateString()}
  • @@ -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((acc, message) => { + + let conversations: Record = {}; + 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] = []; + if ( + !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({ - ...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 }, diff --git a/src/pages/settings/index.tsx b/src/pages/settings/index.tsx index a63b5b7..c43a091 100644 --- a/src/pages/settings/index.tsx +++ b/src/pages/settings/index.tsx @@ -11,18 +11,20 @@ type Props = InferGetServerSidePropsType; 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"}) => + icon: ({ className = "w-8 h-8" }) => , }, { name: "Billing", href: "/settings/billing", - icon: ({className = "w-8 h-8"}) => + icon: ({ className = "w-8 h-8" }) => , }, ]; +/* eslint-enable react/display-name */ const Settings: NextPage = (props) => { return ( @@ -34,9 +36,9 @@ const Settings: NextPage = (props) => { - + {item.name} ))} diff --git a/src/pages/welcome/step-three.tsx b/src/pages/welcome/step-three.tsx index f78727e..07de30f 100644 --- a/src/pages/welcome/step-three.tsx +++ b/src/pages/welcome/step-three.tsx @@ -46,7 +46,7 @@ const StepThree: NextPage = ({ hasTwilioCredentials, availablePhoneNumber previous={{ href: "/welcome/step-two", label: "Back" }} >
    - You don't have any phone number, fill your Twilio credentials first + You don't have any phone number, fill your Twilio credentials first
    ) @@ -90,7 +90,7 @@ const StepThree: NextPage = ({ 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();