diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb91904..592cac3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,8 @@ jobs: # This step uses the `docker/build-push-action` action to build the image, based on your repository's `Dockerfile`. If the build succeeds, it pushes the image to GitHub Packages. # It uses the `context` parameter to define the build's context as the set of files located in the specified path. For more information, see "[Usage](https://github.com/docker/build-push-action#usage)" in the README of the `docker/build-push-action` repository. # It uses the `tags` and `labels` parameters to tag and label the image with the output from the "meta" step. + + # Use env for build next.js https://dev.to/jpoehnelt/environment-variables-in-github-docker-build-push-action-23pj - name: Build and push Docker image uses: docker/build-push-action@v5 with: @@ -50,4 +52,5 @@ jobs: push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha \ No newline at end of file + cache-from: type=gha + \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..881c7ce --- /dev/null +++ b/README.md @@ -0,0 +1,4 @@ +# My Budget App + +## Setup Google Sheet Cred +https://theoephraim.github.io/node-google-spreadsheet/#/guides/authentication diff --git a/background-job/.env.sample b/background-job/.env.sample index 0d07f45..3c7fde3 100644 --- a/background-job/.env.sample +++ b/background-job/.env.sample @@ -1,2 +1,5 @@ -NOTION_KEY= -DAILY_JOURNAL_DATABASE_ID= \ No newline at end of file +GSHEET_PRIVATE_KEY= +GSHEET_CLIENT_EMAIL= +GSHEET_SPREADSHEET_ID= + +GSHEET_SHEET_TRANSACTION_SHEET_ID= \ No newline at end of file diff --git a/background-job/package-lock.json b/background-job/package-lock.json index 793e143..eaf9b90 100644 --- a/background-job/package-lock.json +++ b/background-job/package-lock.json @@ -11,7 +11,11 @@ "dependencies": { "@azure/functions": "^4.1.0", "dayjs": "^1.11.10", - "nammatham": "2.0.0-alpha.13" + "google-auth-library": "^9.4.1", + "google-spreadsheet": "^4.1.1", + "nammatham": "2.0.0-alpha.13", + "zod": "^3.22.4", + "zod-validation-error": "^3.1.0" }, "devDependencies": { "cross-env": "^7.0.3", @@ -582,6 +586,38 @@ "node": ">= 0.6" } }, + "node_modules/agent-base": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", + "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", + "dependencies": { + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/agent-base/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/agent-base/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -658,6 +694,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, "node_modules/atomic-sleep": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", @@ -681,6 +722,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/axios": { + "version": "1.6.8", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.8.tgz", + "integrity": "sha512-v/ZHtJDU39mDpyBoFVkETcd/uNdxrWRrg3bKpOKzXFA6Bvqopts6ALSMU3y6ijYxbw2B+wPrIv46egTzJXCLGQ==", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -706,6 +757,14 @@ } ] }, + "node_modules/bignumber.js": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.1.2.tgz", + "integrity": "sha512-2/mKyZH9K85bzOEfhXDBFZTGd1CTs+5IHpeFQo9luiBG7hghdC851Pj2WAhb6E3R6b9tZj/XKhbg4fum+Kepug==", + "engines": { + "node": "*" + } + }, "node_modules/body-parser": { "version": "1.20.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", @@ -762,6 +821,11 @@ "ieee754": "^1.2.1" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" + }, "node_modules/busboy": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", @@ -838,6 +902,17 @@ "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -1014,6 +1089,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -1043,6 +1126,14 @@ "url": "https://dotenvx.com" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -1311,6 +1402,11 @@ "node": ">= 0.10.0" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" + }, "node_modules/fast-redact": { "version": "3.5.0", "resolved": "https://registry.npmjs.org/fast-redact/-/fast-redact-3.5.0.tgz", @@ -1336,6 +1432,25 @@ "node": ">= 0.8" } }, + "node_modules/follow-redirects": { + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", + "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/for-each": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", @@ -1345,6 +1460,19 @@ "is-callable": "^1.1.3" } }, + "node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -1410,6 +1538,33 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gaxios": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.4.0.tgz", + "integrity": "sha512-apAloYrY4dlBGlhauDAYSZveafb5U6+L9titing1wox6BvWM0TSXBp603zTrLpyLMGkrcFgohnUN150dFN/zOA==", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/gcp-metadata": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.0.tgz", + "integrity": "sha512-Jh/AIwwgaxan+7ZUUmRLCjtchyDiqh4KjBJ5tW3plBZb5iL/BPcso8A5DlzeD9qlw0duCamnNdpFjxwaT0KyKg==", + "dependencies": { + "gaxios": "^6.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/get-intrinsic": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", @@ -1472,6 +1627,39 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/google-auth-library": { + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.7.0.tgz", + "integrity": "sha512-I/AvzBiUXDzLOy4iIZ2W+Zq33W4lcukQv1nl7C8WUA6SQwyQwUwu3waNmWNAvzds//FG8SZ+DnKnW/2k6mQS8A==", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^6.1.1", + "gcp-metadata": "^6.1.0", + "gtoken": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-spreadsheet": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/google-spreadsheet/-/google-spreadsheet-4.1.1.tgz", + "integrity": "sha512-Npk/xAMTgxEt/m/X9EXIqdY6CEYGiqUHrSuiLnNSKli5H+wiOQLSLsnfMxcdNPH6aSh6GttZm6QJhrnsxjwpZQ==", + "dependencies": { + "axios": "^1.4.0", + "lodash": "^4.17.21" + }, + "peerDependencies": { + "google-auth-library": "^8.8.0 || ^9.0.0" + }, + "peerDependenciesMeta": { + "google-auth-library": { + "optional": true + } + } + }, "node_modules/gopd": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", @@ -1489,6 +1677,18 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "dev": true }, + "node_modules/gtoken": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", + "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", + "dependencies": { + "gaxios": "^6.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/has-bigints": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", @@ -1586,6 +1786,39 @@ "node": ">= 0.8" } }, + "node_modules/https-proxy-agent": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.4.tgz", + "integrity": "sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg==", + "dependencies": { + "agent-base": "^7.0.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -1805,6 +2038,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-string": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", @@ -1882,12 +2126,39 @@ "node": ">=10" } }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, "node_modules/json-parse-better-errors": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", "dev": true }, + "node_modules/jwa": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz", + "integrity": "sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==", + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", + "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "dependencies": { + "jwa": "^2.0.0", + "safe-buffer": "^5.0.1" + } + }, "node_modules/leven": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-2.1.0.tgz", @@ -1911,6 +2182,11 @@ "node": ">=4" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, "node_modules/long": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", @@ -2025,6 +2301,25 @@ "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", "dev": true }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/normalize-package-data": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", @@ -2450,6 +2745,11 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, "node_modules/pump": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", @@ -2980,6 +3280,11 @@ "node": ">=0.6" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, "node_modules/tsx": { "version": "4.7.2", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.7.2.tgz", @@ -3174,6 +3479,20 @@ "node": ">= 0.8" } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -3228,6 +3547,25 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + }, + "node_modules/zod": { + "version": "3.22.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz", + "integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-3.1.0.tgz", + "integrity": "sha512-zujS6HqJjMZCsvjfbnRs7WI3PXN39ovTcY1n8a+KTm4kOH0ZXYsNiJkH1odZf4xZKMkBDL7M2rmQ913FCS1p9w==", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.18.0" + } } }, "dependencies": { @@ -3571,6 +3909,29 @@ "negotiator": "0.6.3" } }, + "agent-base": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", + "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", + "requires": { + "debug": "^4.3.4" + }, + "dependencies": { + "debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "requires": { + "ms": "2.1.2" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + } + } + }, "ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -3626,6 +3987,11 @@ "is-shared-array-buffer": "^1.0.2" } }, + "asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, "atomic-sleep": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", @@ -3640,6 +4006,16 @@ "possible-typed-array-names": "^1.0.0" } }, + "axios": { + "version": "1.6.8", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.8.tgz", + "integrity": "sha512-v/ZHtJDU39mDpyBoFVkETcd/uNdxrWRrg3bKpOKzXFA6Bvqopts6ALSMU3y6ijYxbw2B+wPrIv46egTzJXCLGQ==", + "requires": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -3651,6 +4027,11 @@ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" }, + "bignumber.js": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.1.2.tgz", + "integrity": "sha512-2/mKyZH9K85bzOEfhXDBFZTGd1CTs+5IHpeFQo9luiBG7hghdC851Pj2WAhb6E3R6b9tZj/XKhbg4fum+Kepug==" + }, "body-parser": { "version": "1.20.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", @@ -3689,6 +4070,11 @@ "ieee754": "^1.2.1" } }, + "buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" + }, "busboy": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", @@ -3747,6 +4133,14 @@ "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==" }, + "combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "requires": { + "delayed-stream": "~1.0.0" + } + }, "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -3868,6 +4262,11 @@ "object-keys": "^1.1.1" } }, + "delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==" + }, "depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -3884,6 +4283,14 @@ "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", "dev": true }, + "ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "requires": { + "safe-buffer": "^5.0.1" + } + }, "ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -4103,6 +4510,11 @@ "vary": "~1.1.2" } }, + "extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" + }, "fast-redact": { "version": "3.5.0", "resolved": "https://registry.npmjs.org/fast-redact/-/fast-redact-3.5.0.tgz", @@ -4122,6 +4534,11 @@ "unpipe": "~1.0.0" } }, + "follow-redirects": { + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", + "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==" + }, "for-each": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", @@ -4131,6 +4548,16 @@ "is-callable": "^1.1.3" } }, + "form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + } + }, "forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -4171,6 +4598,27 @@ "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", "dev": true }, + "gaxios": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.4.0.tgz", + "integrity": "sha512-apAloYrY4dlBGlhauDAYSZveafb5U6+L9titing1wox6BvWM0TSXBp603zTrLpyLMGkrcFgohnUN150dFN/zOA==", + "requires": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9", + "uuid": "^9.0.1" + } + }, + "gcp-metadata": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.0.tgz", + "integrity": "sha512-Jh/AIwwgaxan+7ZUUmRLCjtchyDiqh4KjBJ5tW3plBZb5iL/BPcso8A5DlzeD9qlw0duCamnNdpFjxwaT0KyKg==", + "requires": { + "gaxios": "^6.0.0", + "json-bigint": "^1.0.0" + } + }, "get-intrinsic": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", @@ -4212,6 +4660,28 @@ "define-properties": "^1.1.3" } }, + "google-auth-library": { + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.7.0.tgz", + "integrity": "sha512-I/AvzBiUXDzLOy4iIZ2W+Zq33W4lcukQv1nl7C8WUA6SQwyQwUwu3waNmWNAvzds//FG8SZ+DnKnW/2k6mQS8A==", + "requires": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^6.1.1", + "gcp-metadata": "^6.1.0", + "gtoken": "^7.0.0", + "jws": "^4.0.0" + } + }, + "google-spreadsheet": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/google-spreadsheet/-/google-spreadsheet-4.1.1.tgz", + "integrity": "sha512-Npk/xAMTgxEt/m/X9EXIqdY6CEYGiqUHrSuiLnNSKli5H+wiOQLSLsnfMxcdNPH6aSh6GttZm6QJhrnsxjwpZQ==", + "requires": { + "axios": "^1.4.0", + "lodash": "^4.17.21" + } + }, "gopd": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", @@ -4226,6 +4696,15 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "dev": true }, + "gtoken": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", + "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", + "requires": { + "gaxios": "^6.0.0", + "jws": "^4.0.0" + } + }, "has-bigints": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", @@ -4290,6 +4769,30 @@ "toidentifier": "1.0.1" } }, + "https-proxy-agent": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.4.tgz", + "integrity": "sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg==", + "requires": { + "agent-base": "^7.0.2", + "debug": "4" + }, + "dependencies": { + "debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "requires": { + "ms": "2.1.2" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + } + } + }, "iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -4426,6 +4929,11 @@ "call-bind": "^1.0.7" } }, + "is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==" + }, "is-string": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", @@ -4479,12 +4987,39 @@ "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==" }, + "json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "requires": { + "bignumber.js": "^9.0.0" + } + }, "json-parse-better-errors": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", "dev": true }, + "jwa": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz", + "integrity": "sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==", + "requires": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "jws": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", + "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "requires": { + "jwa": "^2.0.0", + "safe-buffer": "^5.0.1" + } + }, "leven": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-2.1.0.tgz", @@ -4502,6 +5037,11 @@ "strip-bom": "^3.0.0" } }, + "lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, "long": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", @@ -4586,6 +5126,14 @@ "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", "dev": true }, + "node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "requires": { + "whatwg-url": "^5.0.0" + } + }, "normalize-package-data": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", @@ -4903,6 +5451,11 @@ "ipaddr.js": "1.9.1" } }, + "proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, "pump": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", @@ -5292,6 +5845,11 @@ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==" }, + "tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, "tsx": { "version": "4.7.2", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.7.2.tgz", @@ -5425,6 +5983,20 @@ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==" }, + "webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "requires": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -5464,6 +6036,17 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + }, + "zod": { + "version": "3.22.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz", + "integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==" + }, + "zod-validation-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-3.1.0.tgz", + "integrity": "sha512-zujS6HqJjMZCsvjfbnRs7WI3PXN39ovTcY1n8a+KTm4kOH0ZXYsNiJkH1odZf4xZKMkBDL7M2rmQ913FCS1p9w==", + "requires": {} } } } diff --git a/background-job/package.json b/background-job/package.json index b13b0e3..6b824b7 100644 --- a/background-job/package.json +++ b/background-job/package.json @@ -16,7 +16,11 @@ "dependencies": { "@azure/functions": "^4.1.0", "dayjs": "^1.11.10", - "nammatham": "2.0.0-alpha.13" + "google-auth-library": "^9.4.1", + "google-spreadsheet": "^4.1.1", + "nammatham": "2.0.0-alpha.13", + "zod": "^3.22.4", + "zod-validation-error": "^3.1.0" }, "devDependencies": { "cross-env": "^7.0.3", diff --git a/background-job/src/bootstrap.ts b/background-job/src/bootstrap.ts new file mode 100644 index 0000000..39f6a80 --- /dev/null +++ b/background-job/src/bootstrap.ts @@ -0,0 +1,18 @@ +import { env } from './env'; +import { JWT } from 'google-auth-library'; +import { GoogleSpreadsheet } from 'google-spreadsheet'; + +/** + * Google Sheet Service + */ + +const serviceAccountAuth = new JWT({ + email: env.GSHEET_CLIENT_EMAIL, + key: env.GSHEET_PRIVATE_KEY, + /** + * Authentication Scope, read more: https://theoephraim.github.io/node-google-spreadsheet/#/guides/authentication?id=auth-scopes + */ + scopes: ['https://www.googleapis.com/auth/spreadsheets', 'https://www.googleapis.com/auth/drive.file'], +}); + +export const sheetDoc = new GoogleSpreadsheet(env.GSHEET_SPREADSHEET_ID, serviceAccountAuth); diff --git a/background-job/src/env.ts b/background-job/src/env.ts new file mode 100644 index 0000000..16f12b5 --- /dev/null +++ b/background-job/src/env.ts @@ -0,0 +1,76 @@ +import { z } from "zod"; +import { fromZodError } from "zod-validation-error"; +import 'dotenv/config'; + +export const envSchema = z.object({ + /** + * Google Sheet Private Key + * + * NOTE: + * we need to replace the escaped newline characters + * https://stackoverflow.com/questions/50299329/node-js-firebase-service-account-private-key-wont-parse + */ + GSHEET_PRIVATE_KEY: z.preprocess((value) => { + if (value === undefined) return ""; + if (typeof value !== "string") { + throw new Error("GSHEET_PRIVATE_KEY must be a string"); + } + return value.replace(/\\n/g, "\n"); + }, z.string()), + /** + * Google Sheet Client Email + */ + GSHEET_CLIENT_EMAIL: z.string().default(""), + /** + * Google Sheet ID + */ + GSHEET_SPREADSHEET_ID: z.string().default(""), + /** + * Google Sheet, Transaction Sheet ID + */ + GSHEET_SHEET_TRANSACTION_SHEET_ID: z.preprocess((value) => { + if (value === undefined) return -1; + if (typeof value !== "string") { + throw new Error("GSHEET_SHEET_TRANSACTION_SHEET_ID must be a string"); + } + return parseInt(value, 10); + }, z.number()), + + /** + * Timezone + */ + TIMEZONE: z.string().default("Asia/Bangkok"), +}); + +function printSecretFields( + data: Record, + secretFields: string[] +) { + const parsedEnv: Record = {}; + for (const [key, value] of Object.entries(data)) { + parsedEnv[key] = secretFields.includes(key as any) + ? `${String(value).substring(0, 10)}${value === "" ? "" : "..."}` + : value; + } + return parsedEnv; +} + +function parseZodPrettyError(env: Record) { + try { + const data = envSchema.parse(env); + const parsedEnv = printSecretFields(data, [ + "GSHEET_PRIVATE_KEY", + ] as (keyof typeof data)[]); + console.debug("Environment Variables: ", parsedEnv); + return data; + } catch (error) { + if (error instanceof z.ZodError) { + throw new Error( + "Invalid environment variables: " + fromZodError(error).message + ); + } + throw error; + } +} + +export const env = parseZodPrettyError(process.env); diff --git a/background-job/src/functions/add-transaction-queue.ts b/background-job/src/functions/add-transaction-queue.ts index 2af1abc..6211169 100644 --- a/background-job/src/functions/add-transaction-queue.ts +++ b/background-job/src/functions/add-transaction-queue.ts @@ -1,14 +1,59 @@ +import { z } from 'zod'; import { func } from '../nammatham'; +import { dateStringTimezone, dateTimeStringTimezone } from '../libs/dayjs'; +import { updateExistingSheet } from '../libs/google-sheet'; +import { sheetDoc } from '../bootstrap'; +import { env } from '../env'; + +const transactionPostSchema = z.object({ + type: z.enum(['add_transaction_queue']), + amount: z.number(), + payee: z.string().nullable(), + category: z.string().nullable(), + account: z.string().nullable(), + date: z.string().datetime().nullable(), + memo: z.string().nullable(), +}); + +export interface GsheetTransactionModel { + Amount: number; + Payee: string; + Category: string; + Account: string; + Date: string; + Memo: string; + CreatedAt: string; +} + +function parseTransactionToGoogleSheet(data: z.infer): GsheetTransactionModel { + return { + /** + * Transaction amount is negative, however, + * we need to store it as positive for better experience while using only google sheet + */ + Amount: parseFloat(data.amount.toFixed(2)) * -1, + Payee: data.payee ?? '', + Category: data.category ?? '', + Account: data.account ?? '', + Date: data.date ? dateStringTimezone(data.date) : '', + Memo: data.memo ?? '', + CreatedAt: dateTimeStringTimezone(new Date()), + }; +} export default func .storageQueue('addTransactionQueue', { connection: 'AzureWebJobsStorage', - queueName: 'devqueue', + queueName: 'budgetqueue', }) .handler(async c => { console.log('Storage queue function processed work item:', c.trigger); const triggerMetadata = c.context.triggerMetadata; - console.log('Queue metadata:', triggerMetadata); - - // return c.text('Triggered'); + console.log('Queue metadata (dequeueCount):', triggerMetadata?.dequeueCount); + console.log('Queue metadata (insertionTime):', triggerMetadata?.insertionTime); + console.log('Queue metadata (expirationTime):', triggerMetadata?.expirationTime); + const data = transactionPostSchema.parse(c.trigger); + const googleSheetData = parseTransactionToGoogleSheet(data); + console.log('Parsed data:', googleSheetData); + await updateExistingSheet(sheetDoc, env.GSHEET_SHEET_TRANSACTION_SHEET_ID, googleSheetData as any); }); diff --git a/background-job/src/functions/manual-trigger.ts b/background-job/src/functions/manual-trigger.ts deleted file mode 100644 index 6c6e82f..0000000 --- a/background-job/src/functions/manual-trigger.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { func } from '../nammatham'; - -export default func - .httpGet('manual', { - authLevel: 'function', - }) - .handler(async c => { - return c.text('Triggered'); - }); diff --git a/background-job/src/libs/dayjs.ts b/background-job/src/libs/dayjs.ts new file mode 100644 index 0000000..ab46f98 --- /dev/null +++ b/background-job/src/libs/dayjs.ts @@ -0,0 +1,30 @@ +import dayjs from 'dayjs'; +import utc from 'dayjs/plugin/utc'; +import timezone from 'dayjs/plugin/timezone'; +import { env } from '../env'; + +dayjs.extend(utc); +dayjs.extend(timezone); + +const defaultTimezone = env.TIMEZONE; + +export function dayjsUTC(date?: dayjs.ConfigType) { + return dayjs(date).utc(); +} + +export function dateTimeString(date?: dayjs.ConfigType) { + return dayjs(date).format('MM/DD/YYYY HH:mm:ss'); +} + +export function dateString(date?: dayjs.ConfigType) { + return dayjs(date).format('MM/DD/YYYY'); +} + +export function dateStringTimezone(date?: dayjs.ConfigType) { + return dayjs(date).tz(defaultTimezone, true).format('MM/DD/YYYY'); +} + +export function dateTimeStringTimezone(date?: dayjs.ConfigType) { + return dayjs(date).tz(defaultTimezone, true).format('MM/DD/YYYY HH:mm:ss'); +} + diff --git a/background-job/src/libs/google-sheet.ts b/background-job/src/libs/google-sheet.ts new file mode 100644 index 0000000..3f3312e --- /dev/null +++ b/background-job/src/libs/google-sheet.ts @@ -0,0 +1,22 @@ +import { GoogleSpreadsheet } from "google-spreadsheet"; + +/** + * Type from google-spreadsheet package + */ +type RowCellData = string | number | boolean | Date; +type RawRowData = RowCellData[] | Record; + +export async function updateExistingSheet( + doc: GoogleSpreadsheet, + sheetId: number, + row: RawRowData, +) { + await doc.loadInfo(); + const sheet = doc.sheetsById[sheetId]; + doc.resetLocalCache(); + const rows = await sheet.getRows(); + await sheet.addRow(row); + return { + rowCount: rows.length, + }; +} diff --git a/background-job/src/main.ts b/background-job/src/main.ts index b881c74..319eb26 100644 --- a/background-job/src/main.ts +++ b/background-job/src/main.ts @@ -1,10 +1,9 @@ import 'dotenv/config'; import { expressPlugin } from 'nammatham'; import { app } from './nammatham'; -import testTrigger from './functions/manual-trigger'; import addTransactionQueue from './functions/add-transaction-queue'; -app.addFunctions(testTrigger, addTransactionQueue); +app.addFunctions(addTransactionQueue); const dev = process.env.NODE_ENV === 'development'; app.register( diff --git a/budget-app/.env.example b/budget-app/.env.example index 4c8e769..772da50 100644 --- a/budget-app/.env.example +++ b/budget-app/.env.example @@ -1,5 +1,7 @@ GSHEET_PRIVATE_KEY= GSHEET_CLIENT_EMAIL= +GSHEET_SPREADSHEET_ID= +GSHEET_SHEET_TRANSACTION_SHEET_ID=23423423 ENABLE_MICROSOFT_ENTRA_IDENTITY=true ALLOW_WHITELIST_PRINCIPAL_NAMES=* diff --git a/budget-app/package-lock.json b/budget-app/package-lock.json index 877911f..a0cfcba 100644 --- a/budget-app/package-lock.json +++ b/budget-app/package-lock.json @@ -8,6 +8,7 @@ "name": "my-budget-app", "version": "0.1.0", "dependencies": { + "@azure/data-tables": "^13.2.2", "@azure/identity": "^4.0.1", "@azure/storage-queue": "^12.16.0", "@emotion/react": "^11.11.4", @@ -28,6 +29,7 @@ "react-hook-form": "^7.51.2", "react-number-format": "^5.3.4", "sonner": "^1.4.41", + "ts-odata-client": "^2.0.2", "vaul": "^0.9.0", "zod": "^3.22.4", "zod-validation-error": "^3.1.0" @@ -238,6 +240,37 @@ "node": ">=18.0.0" } }, + "node_modules/@azure/core-xml": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@azure/core-xml/-/core-xml-1.4.2.tgz", + "integrity": "sha512-CW3MZhApe/S4iikbYKE7s83fjDBPIr2kpidX+hlGRwh7N4o1nIpQ/PfJTeioqhfqdMvRtheEl+ft64fyTaLNaA==", + "dependencies": { + "fast-xml-parser": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/data-tables": { + "version": "13.2.2", + "resolved": "https://registry.npmjs.org/@azure/data-tables/-/data-tables-13.2.2.tgz", + "integrity": "sha512-Dq2Aq0mMMF0BPzYQKdBY/OtO7VemP/foh6z+mJpUO1hRL+65C1rGQUJf20LJHotSyU8wHb4HJzOs+Z50GXSy1w==", + "dependencies": { + "@azure/core-auth": "^1.3.0", + "@azure/core-client": "^1.0.0", + "@azure/core-paging": "^1.1.1", + "@azure/core-rest-pipeline": "^1.1.0", + "@azure/core-tracing": "^1.0.0", + "@azure/core-xml": "^1.0.0", + "@azure/logger": "^1.0.0", + "tslib": "^2.2.0", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@azure/identity": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.1.0.tgz", @@ -3558,6 +3591,27 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true }, + "node_modules/fast-xml-parser": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.3.6.tgz", + "integrity": "sha512-M2SovcRxD4+vC493Uc2GZVcZaj66CCJhWurC4viynVSTvrpErCShNcDz1lAho6n9REQKvL/ll4A4/fw6Y9z8nw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + }, + { + "type": "paypal", + "url": "https://paypal.me/naturalintelligence" + } + ], + "dependencies": { + "strnum": "^1.0.5" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fastq": { "version": "1.17.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", @@ -6444,6 +6498,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strnum": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.0.5.tgz", + "integrity": "sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==" + }, "node_modules/styled-jsx": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz", @@ -6632,6 +6691,11 @@ "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", "dev": true }, + "node_modules/ts-odata-client": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/ts-odata-client/-/ts-odata-client-2.0.2.tgz", + "integrity": "sha512-H+vROPaW9pvAtraUdrPVFN28VjMzn6dox9Qy3hHKRQg4l+S0KP6V+D295F8gqZSBEkeTTIXZRvYy0xkcMjGBsg==" + }, "node_modules/tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", @@ -7323,6 +7387,31 @@ } } }, + "@azure/core-xml": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@azure/core-xml/-/core-xml-1.4.2.tgz", + "integrity": "sha512-CW3MZhApe/S4iikbYKE7s83fjDBPIr2kpidX+hlGRwh7N4o1nIpQ/PfJTeioqhfqdMvRtheEl+ft64fyTaLNaA==", + "requires": { + "fast-xml-parser": "^4.3.2", + "tslib": "^2.6.2" + } + }, + "@azure/data-tables": { + "version": "13.2.2", + "resolved": "https://registry.npmjs.org/@azure/data-tables/-/data-tables-13.2.2.tgz", + "integrity": "sha512-Dq2Aq0mMMF0BPzYQKdBY/OtO7VemP/foh6z+mJpUO1hRL+65C1rGQUJf20LJHotSyU8wHb4HJzOs+Z50GXSy1w==", + "requires": { + "@azure/core-auth": "^1.3.0", + "@azure/core-client": "^1.0.0", + "@azure/core-paging": "^1.1.1", + "@azure/core-rest-pipeline": "^1.1.0", + "@azure/core-tracing": "^1.0.0", + "@azure/core-xml": "^1.0.0", + "@azure/logger": "^1.0.0", + "tslib": "^2.2.0", + "uuid": "^8.3.0" + } + }, "@azure/identity": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.1.0.tgz", @@ -9544,6 +9633,14 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true }, + "fast-xml-parser": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.3.6.tgz", + "integrity": "sha512-M2SovcRxD4+vC493Uc2GZVcZaj66CCJhWurC4viynVSTvrpErCShNcDz1lAho6n9REQKvL/ll4A4/fw6Y9z8nw==", + "requires": { + "strnum": "^1.0.5" + } + }, "fastq": { "version": "1.17.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", @@ -11499,6 +11596,11 @@ "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true }, + "strnum": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.0.5.tgz", + "integrity": "sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==" + }, "styled-jsx": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz", @@ -11633,6 +11735,11 @@ "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", "dev": true }, + "ts-odata-client": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/ts-odata-client/-/ts-odata-client-2.0.2.tgz", + "integrity": "sha512-H+vROPaW9pvAtraUdrPVFN28VjMzn6dox9Qy3hHKRQg4l+S0KP6V+D295F8gqZSBEkeTTIXZRvYy0xkcMjGBsg==" + }, "tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", diff --git a/budget-app/package.json b/budget-app/package.json index 70ef635..5cc9037 100644 --- a/budget-app/package.json +++ b/budget-app/package.json @@ -10,6 +10,7 @@ "azurite": "npx azurite --silent --location ./.azurite --debug ./.azurite/debug.log" }, "dependencies": { + "@azure/data-tables": "^13.2.2", "@azure/identity": "^4.0.1", "@azure/storage-queue": "^12.16.0", "@emotion/react": "^11.11.4", @@ -30,6 +31,7 @@ "react-hook-form": "^7.51.2", "react-number-format": "^5.3.4", "sonner": "^1.4.41", + "ts-odata-client": "^2.0.2", "vaul": "^0.9.0", "zod": "^3.22.4", "zod-validation-error": "^3.1.0" diff --git a/budget-app/src/app/api/select/account/route.ts b/budget-app/src/app/api/select/account/route.ts new file mode 100644 index 0000000..3924945 --- /dev/null +++ b/budget-app/src/app/api/select/account/route.ts @@ -0,0 +1,10 @@ +import { globalHandler } from "@/global/globalHandler"; +import { NextResponse } from "next/server"; +import { getSelectFromAzureTableByType } from "../helpers"; + +export const GET = globalHandler(async () => { + return NextResponse.json({ + message: "OK", + data: await getSelectFromAzureTableByType("account"), + }); +}); diff --git a/budget-app/src/app/api/select/category/route.ts b/budget-app/src/app/api/select/category/route.ts new file mode 100644 index 0000000..f3839f3 --- /dev/null +++ b/budget-app/src/app/api/select/category/route.ts @@ -0,0 +1,10 @@ +import { globalHandler } from "@/global/globalHandler"; +import { NextResponse } from "next/server"; +import { getSelectFromAzureTableByType } from "../helpers"; + +export const GET = globalHandler(async () => { + return NextResponse.json({ + message: "OK", + data: await getSelectFromAzureTableByType("category"), + }); +}); diff --git a/budget-app/src/app/api/select/helpers.ts b/budget-app/src/app/api/select/helpers.ts new file mode 100644 index 0000000..7cae9e3 --- /dev/null +++ b/budget-app/src/app/api/select/helpers.ts @@ -0,0 +1,24 @@ +import { selectTable } from "@/bootstrap"; +import { SelectEntity } from "@/entites/select.entity"; +import { ODataExpression } from "ts-odata-client"; + +export async function getSelectFromAzureTableByType(type: string) { + const filterQuery = ODataExpression.forV4() + .filter((p) => p.type.$equals(type)) + .build(); + const tableResult = selectTable.list({ + filter: filterQuery.filter, + }); + const data = []; + for await (const entity of tableResult) { + data.push({ + /** + * It will use the ID later, currently use label as value for setting into google sheet + */ + id: entity.label, + label: entity.label, + order: entity.order, + }); + } + return data.sort((a, b) => a.order - b.order); +} diff --git a/budget-app/src/app/api/transaction/queue/route.ts b/budget-app/src/app/api/transaction/queue/route.ts index 51484ef..e3d0034 100644 --- a/budget-app/src/app/api/transaction/queue/route.ts +++ b/budget-app/src/app/api/transaction/queue/route.ts @@ -1,15 +1,17 @@ import { customError } from "@/global/errorHandler"; import { globalHandler } from "@/global/globalHandler"; -import { queue } from "@/libs/azure-storage-queue"; +import { poisonQueue, queue } from "@/bootstrap"; import { NextResponse } from "next/server"; export const GET = globalHandler(async () => { try { - const numberOfMessages = await queue.length(); return NextResponse.json({ success: true, data: { - numberOfMessages, + numberOfMessages: await queue.length(), + poisonQueue: { + numberOfMessages: await poisonQueue.length(), + }, }, }); } catch (error) { diff --git a/budget-app/src/app/api/transaction/route.ts b/budget-app/src/app/api/transaction/route.ts index 26624d1..ba293ef 100644 --- a/budget-app/src/app/api/transaction/route.ts +++ b/budget-app/src/app/api/transaction/route.ts @@ -1,10 +1,11 @@ import { customError } from "@/global/errorHandler"; import { globalHandler } from "@/global/globalHandler"; -import { queue } from "@/libs/azure-storage-queue"; +import { queue, sheetDoc } from "@/bootstrap"; import { NextResponse } from "next/server"; import { z } from "zod"; +import { updateExistingSheet } from "@/libs/google-sheet"; +import { env } from "@/env"; -// /** * NOTE: Cannot be export in `route.ts` file * src/app/api/transaction/route.ts @@ -33,3 +34,11 @@ export const POST = globalHandler(async (req) => { throw customError(error, "Failed to send message to the queue"); } }); + +export const GET = globalHandler(async (req) => { + const result = await updateExistingSheet(sheetDoc, env.GSHEET_SHEET_TRANSACTION_SHEET_ID); + return NextResponse.json({ + message: "OK", + result, + }); +}); diff --git a/budget-app/src/app/components/AddTransaction.tsx b/budget-app/src/app/components/AddTransaction.tsx index afc79c2..a798251 100644 --- a/budget-app/src/app/components/AddTransaction.tsx +++ b/budget-app/src/app/components/AddTransaction.tsx @@ -5,6 +5,7 @@ import Container from "@mui/material/Container"; import TextField from "@mui/material/TextField"; import { CurrencyTextField } from "./CurrencyTextField"; import { + Alert, Button, CircularProgress, LinearProgress, @@ -14,32 +15,16 @@ import { GroupAutocompleteTextField } from "./GroupAutocompleteTextField"; import { AutocompleteTextField } from "./AutocompleteTextField"; import SendIcon from "@mui/icons-material/Send"; import { Toaster, toast } from "sonner"; -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import axios from "axios"; import { BaseResponse } from "@/global/response"; import { useForm, SubmitHandler, Controller } from "react-hook-form"; import { ControlledAutocompleteTextField } from "./ControlledAutocompleteTextField"; import dayjs, { Dayjs } from "dayjs"; import { TrasactionPost } from "../api/transaction/route"; - -const options = [ - { - id: "1", - label: "Option 1", - }, - { - id: "2", - label: "Option 2", - }, - { - id: "3", - label: "Option 3", - }, - { - id: "4", - label: "Option 4", - }, -]; +import { InferRouteResponse } from "@/types"; +import type * as SelectAccount from "@/app/api/select/account/route"; +import { catchResponseMessage } from "@/global/catchResponse"; type Inputs = { amount: string; @@ -50,7 +35,29 @@ type Inputs = { memo: string; }; +export type SelectGetResponse = InferRouteResponse< +typeof SelectAccount.GET +>; + export function AddTransactionForm() { + const selectAccountGet = useQuery({ + queryKey: ["selectAccountGet"], + queryFn: () => + axios + .get("/api/select/account") + .then((res) => res.data) + .catch(catchResponseMessage), + }); + + const selectCategoryGet = useQuery({ + queryKey: ["selectCategoryGet"], + queryFn: () => + axios + .get("/api/select/category") + .then((res) => res.data) + .catch(catchResponseMessage), + }); + const saveMutation = useMutation({ mutationKey: ["saveTransaction"], mutationFn: async (data: TrasactionPost) => { @@ -78,6 +85,7 @@ export function AddTransactionForm() { const { control, register, + reset, handleSubmit, formState: { errors }, } = useForm({ @@ -90,6 +98,22 @@ export function AddTransactionForm() { memo: "", }, }); + + if (selectAccountGet.error) { + return ( + + Select Account Error: {selectAccountGet.error?.message} + + ); + } + + if(selectCategoryGet.error) { + return ( + + Select Category Error: {selectCategoryGet.error?.message} + + ); + } const onSubmit: SubmitHandler = (data) => { const parsedData: TrasactionPost = { @@ -103,38 +127,43 @@ export function AddTransactionForm() { }; console.log("Submit data: ", parsedData); saveMutation.mutate(parsedData); + reset(); }; return ( -
- - Add Transaction -
- */} +
: null}
-
); } diff --git a/budget-app/src/app/components/ControlledAutocompleteTextField.tsx b/budget-app/src/app/components/ControlledAutocompleteTextField.tsx index 161673b..6274aa9 100644 --- a/budget-app/src/app/components/ControlledAutocompleteTextField.tsx +++ b/budget-app/src/app/components/ControlledAutocompleteTextField.tsx @@ -40,6 +40,7 @@ export const ControlledAutocompleteTextField = < return ( <> { diff --git a/budget-app/src/app/components/ShowTransactionQueue.tsx b/budget-app/src/app/components/ShowTransactionQueue.tsx index 89928f6..163b670 100644 --- a/budget-app/src/app/components/ShowTransactionQueue.tsx +++ b/budget-app/src/app/components/ShowTransactionQueue.tsx @@ -23,9 +23,7 @@ export function ShowTransactionQueue() { if (transactionQueue.error) { return ( - - Error: {transactionQueue.error?.message} - + Error: {transactionQueue.error?.message} ); } @@ -33,14 +31,26 @@ export function ShowTransactionQueue() { return ; } - if(transactionQueue.data?.data?.numberOfMessages === 0){ - return <>; - } + // if (transactionQueue.data?.data?.numberOfMessages === 0) { + // return <>; + // } return ( - - Number of messages in queue:{" "} - {String(transactionQueue.data?.data.numberOfMessages)} - + <> + {transactionQueue.data?.data?.numberOfMessages && + transactionQueue.data?.data?.numberOfMessages > 0 ? ( + + Number of messages in queue:{" "} + {String(transactionQueue.data?.data.numberOfMessages)} + + ) : null} + {transactionQueue.data?.data?.poisonQueue?.numberOfMessages && + transactionQueue.data?.data?.poisonQueue?.numberOfMessages > 0 ? ( + + Number of messages in poison queue:{" "} + {String(transactionQueue.data?.data.poisonQueue.numberOfMessages)} + + ) : null} + ); } diff --git a/budget-app/src/app/page.tsx b/budget-app/src/app/page.tsx index 938e331..98aca42 100644 --- a/budget-app/src/app/page.tsx +++ b/budget-app/src/app/page.tsx @@ -1,15 +1,16 @@ import * as React from "react"; import { AddTransactionForm } from "./components/AddTransaction"; import { ShowTransactionQueue } from "./components/ShowTransactionQueue"; -import { Container } from "@mui/material"; +import { Container, Typography } from "@mui/material"; export default function Home() { return ( -
+ + + Add Transaction + - - - -
+ + ); } diff --git a/budget-app/src/bootstrap.ts b/budget-app/src/bootstrap.ts new file mode 100644 index 0000000..ecfcc85 --- /dev/null +++ b/budget-app/src/bootstrap.ts @@ -0,0 +1,63 @@ +import { QueueServiceClient } from "@azure/storage-queue"; +import { env } from "./env"; +import { AzureStorageQueue } from "./libs/azure-storage-queue"; +import { JWT } from "google-auth-library"; +import { GoogleSpreadsheet } from "google-spreadsheet"; +import { TableClient } from "@azure/data-tables"; +import { AzureTable } from "./libs/azure-table"; +import { SelectEntity } from "./entites/select.entity"; + +/** + * Azure Storage Queue Client + */ + +const queueServiceClient = QueueServiceClient.fromConnectionString( + env.AZURE_STORAGE_CONNECTION_STRING +); + +export const queue = new AzureStorageQueue( + queueServiceClient, + env.AZURE_STORAGE_QUEUE_NAME +); + +/** + * Azure Storage Queue Poison Client + * + * This queue is used to store the failed messages + * Automatically created by the Azure Function + */ +export const poisonQueue = new AzureStorageQueue( + queueServiceClient, + `${env.AZURE_STORAGE_QUEUE_NAME}-poison` +); + +/** + * Azure Table Client + */ +const selectTableClient = TableClient.fromConnectionString(env.AZURE_STORAGE_CONNECTION_STRING, env.AZURE_STORAGE_TABLE_BUDGET_TABLE_NAME); +export const selectTable = new AzureTable(selectTableClient); +/** + * Google Sheet Service + */ + +const serviceAccountAuth = new JWT({ + email: env.GSHEET_CLIENT_EMAIL, + key: env.GSHEET_PRIVATE_KEY, + /** + * Authentication Scope, read more: https://theoephraim.github.io/node-google-spreadsheet/#/guides/authentication?id=auth-scopes + */ + scopes: [ + "https://www.googleapis.com/auth/spreadsheets", + "https://www.googleapis.com/auth/drive.file", + ], +}); + +export const sheetDoc = new GoogleSpreadsheet( + env.GSHEET_SPREADSHEET_ID, + serviceAccountAuth +); + +// container +// .bind(Tokens.GoogleSpreadsheet) +// .toConstantValue(new GoogleSpreadsheet(env.GSHEET_ID, jwt)); +// container.bind(GoogleSheetService).toSelf().inSingletonScope(); diff --git a/budget-app/src/entites/select.entity.ts b/budget-app/src/entites/select.entity.ts new file mode 100644 index 0000000..f6cdb63 --- /dev/null +++ b/budget-app/src/entites/select.entity.ts @@ -0,0 +1,8 @@ +import { AzureTableEntityBase } from "../libs/azure-table"; + +export interface SelectEntity extends AzureTableEntityBase { + type: "category" | "account"; + id: string; + label: string; + order: number; +} diff --git a/budget-app/src/env.ts b/budget-app/src/env.ts index bfc44c0..8e9f8b5 100644 --- a/budget-app/src/env.ts +++ b/budget-app/src/env.ts @@ -44,13 +44,68 @@ export const envSchema = z.object({ /** * Azure Storage Queue Name */ - AZURE_STORAGE_QUEUE_NAME: z.string().default("devqueue"), + AZURE_STORAGE_QUEUE_NAME: z.string().default("budgetqueue"), + + /** + * Azure Storage Table Name + */ + AZURE_STORAGE_TABLE_BUDGET_TABLE_NAME: z.string().default("BudgetSelect"), + + /** + * Google Sheet Private Key + * + * NOTE: + * we need to replace the escaped newline characters + * https://stackoverflow.com/questions/50299329/node-js-firebase-service-account-private-key-wont-parse + */ + GSHEET_PRIVATE_KEY: z.preprocess((value) => { + if (value === undefined) return ""; + if (typeof value !== "string") { + throw new Error("GSHEET_PRIVATE_KEY must be a string"); + } + return value.replace(/\\n/g, "\n"); + }, z.string()), + /** + * Google Sheet Client Email + */ + GSHEET_CLIENT_EMAIL: z.string().default(""), + /** + * Google Sheet ID + */ + GSHEET_SPREADSHEET_ID: z.string().default(""), + /** + * Google Sheet, Transaction Sheet ID + */ + GSHEET_SHEET_TRANSACTION_SHEET_ID: z.preprocess((value) => { + if (value === undefined) return -1; + if (typeof value !== "string") { + throw new Error("GSHEET_SHEET_TRANSACTION_SHEET_ID must be a string"); + } + return parseInt(value, 10); + }, z.number()), }); +function printSecretFields( + data: Record, + secretFields: string[] +) { + const parsedEnv: Record = {}; + for (const [key, value] of Object.entries(data)) { + parsedEnv[key] = secretFields.includes(key as any) + ? `${String(value).substring(0, 10)}${value === "" ? "" : "..."}` + : value; + } + return parsedEnv; +} + function parseZodPrettyError(env: Record) { try { const data = envSchema.parse(env); - console.log("Environment Variables: ", data); + const parsedEnv = printSecretFields(data, [ + "AZURE_STORAGE_CONNECTION_STRING", + "GSHEET_PRIVATE_KEY", + ] as (keyof typeof data)[]); + console.debug("Environment Variables: ", parsedEnv); return data; } catch (error) { if (error instanceof z.ZodError) { diff --git a/budget-app/src/libs/azure-storage-queue.ts b/budget-app/src/libs/azure-storage-queue.ts index aff57c1..075d374 100644 --- a/budget-app/src/libs/azure-storage-queue.ts +++ b/budget-app/src/libs/azure-storage-queue.ts @@ -1,14 +1,20 @@ import { env } from "@/env"; -import { QueueServiceClient } from "@azure/storage-queue"; +import { QueueClient, QueueServiceClient } from "@azure/storage-queue"; /** * Azure Storage Queue Client * Ref: https://learn.microsoft.com/en-us/azure/storage/queues/storage-quickstart-queues-nodejs?tabs=passwordless%2Croles-azure-portal%2Cenvironment-variable-windows%2Csign-in-azure-cli#add-messages-to-a-queue */ export class AzureStorageQueue { - private queueClient; - constructor(client: QueueServiceClient, queueName: string) { - this.queueClient = client.getQueueClient(queueName); + private queueClient!: QueueClient; + constructor( + protected client: QueueServiceClient, + protected queueName: string + ) {} + + async createQueue() { + await this.client.createQueue(this.queueName); + this.queueClient = this.client.getQueueClient(this.queueName); } async sendMessage(message: string) { @@ -22,6 +28,7 @@ export class AzureStorageQueue { * * Ref: https://learn.microsoft.com/en-us/azure/storage/queues/storage-quickstart-queues-nodejs?tabs=passwordless%2Croles-azure-portal%2Cenvironment-variable-windows%2Csign-in-azure-cli#add-messages-to-a-queue */ + await this.createQueue(); await this.queueClient.sendMessage(Buffer.from(message).toString("base64")); } /** @@ -30,6 +37,7 @@ export class AzureStorageQueue { */ async receiveMessages() { + await this.createQueue(); const response = await this.queueClient.receiveMessages(); return response.receivedMessageItems; } @@ -40,6 +48,7 @@ export class AzureStorageQueue { */ async receiveDecodedMessages() { + await this.createQueue(); const messages = await this.receiveMessages(); return messages.map((message) => { return { @@ -57,20 +66,13 @@ export class AzureStorageQueue { */ async length() { + await this.createQueue(); const response = await this.queueClient.getProperties(); return response.approximateMessagesCount; } async deleteMessage(messageId: string, popReceipt: string) { + await this.createQueue(); await this.queueClient.deleteMessage(messageId, popReceipt); } } - -const queueServiceClient = QueueServiceClient.fromConnectionString( - env.AZURE_STORAGE_CONNECTION_STRING -); - -export const queue = new AzureStorageQueue( - queueServiceClient, - env.AZURE_STORAGE_QUEUE_NAME -); diff --git a/budget-app/src/libs/azure-table.ts b/budget-app/src/libs/azure-table.ts new file mode 100644 index 0000000..498b323 --- /dev/null +++ b/budget-app/src/libs/azure-table.ts @@ -0,0 +1,87 @@ +import { ListTableEntitiesOptions, TableClient, TableServiceClientOptions, TableTransaction } from '@azure/data-tables'; + +export interface AzureTableEntityBase { + partitionKey: string; + rowKey: string; +} + +/** + * Generic Azure Table class + */ +export class AzureTable { + constructor(public readonly client: TableClient) {} + + async createTable() { + return this.client.createTable(); + } + + /** + * Query entities + * TODO: may fix type safety later + * + * select prop type may incorrect + */ + list( + queryOptions?: ListTableEntitiesOptions['queryOptions'], + listTableEntitiesOptions?: Omit + ) { + return this.client.listEntities({ + ...listTableEntitiesOptions, + queryOptions, + }); + } + + async listAll( + queryOptions?: ListTableEntitiesOptions['queryOptions'], + listTableEntitiesOptions?: Omit + ) { + const entities = this.client.listEntities({ + ...listTableEntitiesOptions, + queryOptions, + }); + + const result = []; + // List all the entities in the table + for await (const entity of entities) { + result.push(entity); + } + return result; + } + + async insert(entity: TEntity) { + return this.client.createEntity(entity); + } + + /** + * All operations in a transaction must target the same partitionKey + */ + + async insertBatch(rawEntities: TEntity[]) { + const groupByPartitionKey = this.groupPartitionKey(rawEntities); + for (const entities of Object.values(groupByPartitionKey)) { + const transaction = new TableTransaction(); + entities.forEach(entity => transaction.createEntity(entity)); + await this.client.submitTransaction(transaction.actions); + } + } + + /** + * Group entities by partitionKey + * Becasue all operations in a transaction must target the same partitionKey + * + * @param entities + * @returns + */ + groupPartitionKey(entities: TEntity[]) { + return entities.reduce( + (acc, cur) => { + if (!acc[cur.partitionKey]) { + acc[cur.partitionKey] = []; + } + acc[cur.partitionKey].push(cur); + return acc; + }, + {} as Record + ); + } +} diff --git a/budget-app/src/libs/google-sheet.ts b/budget-app/src/libs/google-sheet.ts index b7f1971..0c8c8ec 100644 --- a/budget-app/src/libs/google-sheet.ts +++ b/budget-app/src/libs/google-sheet.ts @@ -1,3 +1,5 @@ +import { env } from "@/env"; +import { JWT } from "google-auth-library"; import type { GoogleSpreadsheet } from "google-spreadsheet"; export interface GoogleSheetDatabaseOption {