diff --git a/backend/package-lock.json b/backend/package-lock.json index 55d4090f..e9939d24 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -879,116 +879,6 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "license": "ISC", - "optional": true, - "peer": true, - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "license": "MIT", - "optional": true, - "peer": true - }, - "node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -1047,17 +937,6 @@ "node": ">=8.0.0" } }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=14" - } - }, "node_modules/@pkgr/core": { "version": "0.2.9", "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", @@ -2353,6 +2232,10 @@ "fsevents": "~2.3.2" } }, + "node_modules/chokidar/node_modules/fsevents": { + "dev": true, + "optional": true + }, "node_modules/chokidar/node_modules/glob-parent": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", @@ -2610,17 +2493,6 @@ "node": ">= 10" } }, - "node_modules/data-uri-to-buffer": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", - "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 12" - } - }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2771,14 +2643,6 @@ "xtend": "^4.0.0" } }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -3888,31 +3752,6 @@ } } }, - "node_modules/fetch-blob": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", - "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "paypal", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "node-domexception": "^1.0.0", - "web-streams-polyfill": "^3.0.3" - }, - "engines": { - "node": "^12.20 || >= 14.13" - } - }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -4052,24 +3891,6 @@ "dev": true, "license": "ISC" }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "license": "ISC", - "optional": true, - "peer": true, - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/form-data": { "version": "2.5.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.5.tgz", @@ -4120,20 +3941,6 @@ "node": ">=0.4.x" } }, - "node_modules/formdata-polyfill": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", - "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "fetch-blob": "^3.1.2" - }, - "engines": { - "node": ">=12.20.0" - } - }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -4204,67 +4011,6 @@ "uuid": "dist/bin/uuid" } }, - "node_modules/gcp-metadata": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-5.3.0.tgz", - "integrity": "sha512-FNTkdNEnBdlqF2oatizolQqNANMrcqJt6AAYt99B3y1aLLC8Hc5IOBb+ZnnzllodEEf6xMBp6wRcBbc16fa65w==", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "gaxios": "^5.0.0", - "json-bigint": "^1.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/gcp-metadata/node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/gcp-metadata/node_modules/gaxios": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-5.1.3.tgz", - "integrity": "sha512-95hVgBRgEIRQQQHIbnxBXeHbW4TqFk4ZDJW7wmVtvYar72FdhRIo1UGOLS2eRAKCPEdPBWu+M7+A33D9CdX9rA==", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "extend": "^3.0.2", - "https-proxy-agent": "^5.0.0", - "is-stream": "^2.0.0", - "node-fetch": "^2.6.9" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/gcp-metadata/node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -4891,23 +4637,6 @@ "devOptional": true, "license": "ISC" }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "license": "BlueOak-1.0.0", - "optional": true, - "peer": true, - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, "node_modules/jose": { "version": "4.15.9", "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", @@ -6264,17 +5993,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "license": "BlueOak-1.0.0", - "optional": true, - "peer": true, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, "node_modules/mkdirp": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", @@ -6421,73 +6139,6 @@ "node": ">=20.19.0" } }, - "node_modules/mongoose/node_modules/gaxios": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", - "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "extend": "^3.0.2", - "https-proxy-agent": "^7.0.1", - "node-fetch": "^3.3.2", - "rimraf": "^5.0.1" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/mongoose/node_modules/gcp-metadata": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-7.0.1.tgz", - "integrity": "sha512-UcO3kefx6dCcZkgcTGgVOTFb7b1LlQ02hY1omMjjrrBzkajRMCFgYOjs7J71WqnuG1k2b+9ppGL7FsOfhZMQKQ==", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "gaxios": "^7.0.0", - "google-logging-utils": "^1.0.0", - "json-bigint": "^1.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/mongoose/node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "license": "ISC", - "optional": true, - "peer": true, - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/mongoose/node_modules/google-logging-utils": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", - "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "engines": { - "node": ">=14" - } - }, "node_modules/mongoose/node_modules/mongodb": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-7.0.0.tgz", @@ -6547,43 +6198,6 @@ "node": ">=20.19.0" } }, - "node_modules/mongoose/node_modules/node-fetch": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", - "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "data-uri-to-buffer": "^4.0.0", - "fetch-blob": "^3.1.4", - "formdata-polyfill": "^4.0.10" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/node-fetch" - } - }, - "node_modules/mongoose/node_modules/rimraf": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", - "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", - "license": "ISC", - "optional": true, - "peer": true, - "dependencies": { - "glob": "^10.3.7" - }, - "bin": { - "rimraf": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/mpath": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz", @@ -6716,28 +6330,6 @@ "node": ">= 0.6" } }, - "node_modules/node-domexception": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", - "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", - "deprecated": "Use your platform's native DOMException instead", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "github", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=10.5.0" - } - }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -6996,14 +6588,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "license": "BlueOak-1.0.0", - "optional": true, - "peer": true - }, "node_modules/package-manager-detector": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", @@ -7097,32 +6681,6 @@ "dev": true, "license": "MIT" }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "license": "BlueOak-1.0.0", - "optional": true, - "peer": true, - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "license": "ISC", - "optional": true, - "peer": true - }, "node_modules/path-to-regexp": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", @@ -7809,20 +7367,6 @@ "integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==", "license": "MIT" }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "license": "ISC", - "optional": true, - "peer": true, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/simple-update-notifier": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", @@ -7967,23 +7511,6 @@ "node": ">=8" } }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -7997,21 +7524,6 @@ "node": ">=8" } }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", @@ -8697,17 +8209,6 @@ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0" } }, - "node_modules/web-streams-polyfill": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", - "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 8" - } - }, "node_modules/webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", @@ -8797,26 +8298,6 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/backend/src/controllers/volunteerController.ts b/backend/src/controllers/volunteerController.ts index 1b2b45ce..b1bdd6f0 100644 --- a/backend/src/controllers/volunteerController.ts +++ b/backend/src/controllers/volunteerController.ts @@ -98,11 +98,25 @@ type CreateVolunteerBody = { email: string; phoneNumber: string; tags?: string[]; + status?: "returning" | "new"; + volunteerTypeTags?: string[]; + events?: string[]; + additionalNotes?: string; }; export const createVolunteer: RequestHandler = async (req, res, next) => { const errors = validationResult(req); - const { firstName, lastName, email, phoneNumber, tags = [] } = req.body as CreateVolunteerBody; + const { + firstName, + lastName, + email, + phoneNumber, + tags = [], + status = "new", + volunteerTypeTags = [], + events = [], + additionalNotes = "", + } = req.body as CreateVolunteerBody; try { validationErrorParser(errors); @@ -117,6 +131,10 @@ export const createVolunteer: RequestHandler = async (req, res, next) => { email, phoneNumber, tags, + status, + volunteerTypeTags, + events, + additionalNotes, }); res.status(201).json(newVolunteer); } catch (err) { @@ -124,6 +142,68 @@ export const createVolunteer: RequestHandler = async (req, res, next) => { } }; +type UpdateVolunteerBody = { + firstName: string; + lastName: string; + email: string; + phoneNumber: string; + tags?: string[]; + status?: "returning" | "new"; + volunteerTypeTags?: string[]; + events?: string[]; + additionalNotes?: string; +}; + +export const updateVolunteer: RequestHandler = async (req, res, next) => { + const errors = validationResult(req); + const volunteerId = req.params.id; + const { + firstName, + lastName, + email, + phoneNumber, + tags, + status, + volunteerTypeTags, + events, + additionalNotes, + } = req.body as UpdateVolunteerBody; + + try { + validationErrorParser(errors); + + const updatePayload: Record = { + firstName, + lastName, + email, + phoneNumber, + volunteerTypeTags, + events, + additionalNotes, + }; + + if (Array.isArray(tags)) { + updatePayload.tags = tags; + } + if (status === "new" || status === "returning") { + updatePayload.status = status; + } + + const volunteer = await VolunteerModel.findByIdAndUpdate(volunteerId, updatePayload, { + new: true, + runValidators: true, + }).populate(defaultPopulateConfig); + + if (!volunteer) { + return res.status(404).json({ error: "Could not find volunteer" }); + } + + res.status(200).json(volunteer); + } catch (err) { + next(err); + } +}; + type UpdateVolunteerContactBody = { email: string; phoneNumber: string; diff --git a/backend/src/models/volunteerModel.ts b/backend/src/models/volunteerModel.ts index 02e650f2..b6912295 100644 --- a/backend/src/models/volunteerModel.ts +++ b/backend/src/models/volunteerModel.ts @@ -13,9 +13,22 @@ const volunteerSchema = new Schema({ default: [], required: true, }, + volunteerTypeTags: { + type: [String], + default: [], + }, + events: { + type: [String], + default: [], + }, + additionalNotes: { + type: String, + default: "", + }, status: { type: String, enum: ["returning", "new"], + default: "new", }, }); diff --git a/backend/src/routes/volunteerRoutes.ts b/backend/src/routes/volunteerRoutes.ts index bce6b6ca..040db93d 100644 --- a/backend/src/routes/volunteerRoutes.ts +++ b/backend/src/routes/volunteerRoutes.ts @@ -32,6 +32,7 @@ router.get("/", volunteer.getVolunteers); router.delete("/:id", volunteer.deleteVolunteer); router.post("/", VolunteerValidator.createVolunteerValidator, volunteer.createVolunteer); +router.put("/:id", VolunteerValidator.updateVolunteerValidator, volunteer.updateVolunteer); router.put( "/contact/:id", VolunteerValidator.updateVolunteerContactValidator, diff --git a/backend/src/scripts/seedVolunteers.ts b/backend/src/scripts/seedVolunteers.ts index 9f237489..0ff906de 100644 --- a/backend/src/scripts/seedVolunteers.ts +++ b/backend/src/scripts/seedVolunteers.ts @@ -1,12 +1,22 @@ import "dotenv/config"; import { connectToDatabase } from "../database/connect"; +import Tag from "../models/tagModel"; import Volunteer from "../models/volunteerModel"; async function seedVolunteers() { await connectToDatabase(); - await Volunteer.deleteMany({}); // Clear existing volunteers + await Volunteer.deleteMany({}); + await Tag.deleteMany({}); + + const seededTags = await Tag.insertMany([ + { name: "Intern", color: "#3B82F6", type: "Volunteer Type" }, + { name: "Outside Volunteer", color: "#F59E0B", type: "Volunteer Type" }, + { name: "2+ More", color: "#10B981", type: "Event" }, + ]); + + const tagIds = seededTags.map((tag) => tag._id); await Volunteer.insertMany([ { @@ -14,74 +24,74 @@ async function seedVolunteers() { lastName: "Doe", email: "jane@example.com", phoneNumber: "555-123-4567", - tags: ["Intern", "Outside Volunteer", "2+ More"], + tags: tagIds, }, { firstName: "John", lastName: "Smith", email: "john@example.com", phoneNumber: "555-987-6543", - tags: ["Intern", "Outside Volunteer", "2+ More"], + tags: tagIds, }, { firstName: "Alice", lastName: "Johnson", email: "alice@example.com", phoneNumber: "555-222-3344", - tags: ["Intern", "Outside Volunteer", "2+ More"], + tags: tagIds, }, { firstName: "Michael", lastName: "Brown", email: "michael@example.com", phoneNumber: "555-333-7788", - tags: ["Intern", "Outside Volunteer", "2+ More"], + tags: tagIds, }, { firstName: "Sarah", lastName: "Lee", email: "sarah@example.com", phoneNumber: "555-444-9911", - tags: ["Intern", "Outside Volunteer", "2+ More"], + tags: tagIds, }, { firstName: "David", lastName: "Kim", email: "david@example.com", phoneNumber: "555-555-1212", - tags: ["Intern", "Outside Volunteer", "2+ More"], + tags: tagIds, }, { firstName: "Emily", lastName: "Martinez", email: "emily@example.com", phoneNumber: "555-666-3434", - tags: ["Intern", "Outside Volunteer", "2+ More"], + tags: tagIds, }, { firstName: "Chris", lastName: "Wilson", email: "chris@example.com", phoneNumber: "555-777-5656", - tags: ["Intern", "Outside Volunteer", "2+ More"], + tags: tagIds, }, { firstName: "Olivia", lastName: "Nguyen", email: "olivia@example.com", phoneNumber: "555-888-7878", - tags: ["Intern", "Outside Volunteer", "2+ More"], + tags: tagIds, }, { firstName: "Daniel", lastName: "Anderson", email: "daniel@example.com", phoneNumber: "555-999-9090", - tags: ["Intern", "Outside Volunteer", "2+ More"], + tags: tagIds, }, ]); - console.log("Volunteers seeded"); + console.info("Volunteers seeded"); process.exit(0); } diff --git a/backend/src/validators/volunteerValidator.ts b/backend/src/validators/volunteerValidator.ts index cdc2030d..8474a979 100644 --- a/backend/src/validators/volunteerValidator.ts +++ b/backend/src/validators/volunteerValidator.ts @@ -44,12 +44,21 @@ const makePhoneValidator = (path = "phoneNumber") => body(path) .exists() .withMessage("phone is required") - .bail() // What kind of phone number do we want to enforce? - .isMobilePhone("any") - .withMessage("phoneNumber must be a valid mobile phone number") + .bail() + .custom((value: string) => { + const digitsOnly = value.replace(/\D/g, ""); + if (digitsOnly.length !== 10) { + throw new Error("phoneNumber must be a valid phone number"); + } + return true; + }) .customSanitizer((value: string) => value.replace(/\D/g, "")); const tagsValidator = () => body("tags").optional().isArray(); +const statusValidator = () => body("status").optional().isIn(["new", "returning"]); +const volunteerTypeTagsValidator = () => body("volunteerTypeTags").optional().isArray(); +const eventsValidator = () => body("events").optional().isArray(); +const additionalNotesValidator = () => body("additionalNotes").optional().isString(); const batchUploadVolunteersValidator = () => body("volunteers") @@ -74,6 +83,23 @@ export const createVolunteerValidator = [ makeEmailValidator(), makePhoneValidator(), tagsValidator(), + statusValidator(), + volunteerTypeTagsValidator(), + eventsValidator(), + additionalNotesValidator(), +]; + +export const updateVolunteerValidator = [ + makeParamIDValidator(), + makeFirstNameValidator(), + makeLastNameValidator(), + makeEmailValidator(), + makePhoneValidator(), + tagsValidator(), + statusValidator(), + volunteerTypeTagsValidator(), + eventsValidator(), + additionalNotesValidator(), ]; export const updateVolunteerContactValidator = [ diff --git a/frontend/public/ic_close.svg b/frontend/public/ic_close.svg new file mode 100644 index 00000000..aae32ed5 --- /dev/null +++ b/frontend/public/ic_close.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/public/plus.svg b/frontend/public/plus.svg new file mode 100644 index 00000000..a40d571b --- /dev/null +++ b/frontend/public/plus.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/public/redx.svg b/frontend/public/redx.svg new file mode 100644 index 00000000..0bfe381a --- /dev/null +++ b/frontend/public/redx.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/app/api/volunteer.ts b/frontend/src/app/api/volunteer.ts index a18aee10..36dcb74c 100644 --- a/frontend/src/app/api/volunteer.ts +++ b/frontend/src/app/api/volunteer.ts @@ -35,6 +35,14 @@ const normalizeVolunteer = (volunteer: unknown): Volunteer => { lastName: String(source.lastName ?? ""), email: String(source.email ?? ""), phoneNumber: String(source.phoneNumber ?? ""), + status: source.status === "returning" ? "returning" : "new", + volunteerTypeTags: Array.isArray(source.volunteerTypeTags) + ? source.volunteerTypeTags.filter((tag): tag is string => typeof tag === "string") + : [], + events: Array.isArray(source.events) + ? source.events.filter((tag): tag is string => typeof tag === "string") + : [], + additionalNotes: typeof source.additionalNotes === "string" ? source.additionalNotes : "", tags, }; }; diff --git a/frontend/src/app/api/volunteer/[id]/route.ts b/frontend/src/app/api/volunteer/[id]/route.ts new file mode 100644 index 00000000..83b50c12 --- /dev/null +++ b/frontend/src/app/api/volunteer/[id]/route.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from "next/server"; + +const API_URL = process.env.NEXT_PUBLIC_API_URL; + +export async function PUT(request: NextRequest, context: { params: Promise<{ id: string }> }) { + if (!API_URL) { + return NextResponse.json({ error: "NEXT_PUBLIC_API_URL is not configured" }, { status: 500 }); + } + + const token = request.cookies.get("firebaseAuthToken")?.value; + + if (!token) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); + } + + const { id } = await context.params; + + try { + const requestBody = await request.json(); + const response = await fetch(`${API_URL}/api/volunteer/${id}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(requestBody), + cache: "no-store", + }); + + const contentType = response.headers.get("content-type") ?? ""; + const payload = contentType.includes("application/json") + ? await response.json() + : { error: await response.text() }; + + return NextResponse.json(payload, { status: response.status }); + } catch { + return NextResponse.json( + { error: "Unable to reach backend volunteer service" }, + { status: 502 }, + ); + } +} diff --git a/frontend/src/app/volunteers/page.tsx b/frontend/src/app/volunteers/page.tsx index 673df4b8..6955773a 100644 --- a/frontend/src/app/volunteers/page.tsx +++ b/frontend/src/app/volunteers/page.tsx @@ -3,6 +3,7 @@ import { Volunteer } from "@/types/volunteer"; import { fetchVolunteers } from "@/app/api/volunteer"; import VolunteerTable from "@/components/VolunteerTable"; +import VolunteerProfileModal from "@/components/VolunteerProfileModal"; import TitleBar from "@/components/TitleBar"; import SearchBar from "@/components/SearchBar"; import PageBar from "@/components/PageBar"; @@ -22,8 +23,9 @@ export default function Page() { // Pagination state const [currentPage, setCurrentPage] = useState(1); - const [totalItems, setTotalItems] = useState(0); const [showImportSuccess, setShowImportSuccess] = useState(false); + const [selectedVolunteer, setSelectedVolunteer] = useState(null); + const [isSheetOpen, setIsSheetOpen] = useState(false); const itemsPerPage = 6; // Fetch volunteers once on mount @@ -75,6 +77,10 @@ export default function Page() { setShowImportSuccess(true); }; + const handleSheetClose = () => { + setIsSheetOpen(false); + }; + return (
@@ -107,7 +113,13 @@ export default function Page() { selectedVolunteerType={selectedVolunteerType} setSelectedVolunteerType={setSelectedVolunteerType} /> - + { + setSelectedVolunteer(volunteer); + setIsSheetOpen(true); + }} + /> + { + setSelectedVolunteer(updatedVolunteer); + setVolunteers((prev) => + prev.map((volunteer) => + volunteer._id === updatedVolunteer._id ? updatedVolunteer : volunteer, + ), + ); + }} + />
); diff --git a/frontend/src/components/VolunteerProfileModal.module.css b/frontend/src/components/VolunteerProfileModal.module.css new file mode 100644 index 00000000..f227a25e --- /dev/null +++ b/frontend/src/components/VolunteerProfileModal.module.css @@ -0,0 +1,463 @@ +@import url("https://fonts.googleapis.com/css2?family=Viga&display=swap"); + +.backdrop { + display: none; +} + +.modal { + display: flex; + flex-direction: column; + align-items: flex-start; + padding: 40px 0 34px; + position: absolute; + width: 460px; + right: 0; + top: 0; + bottom: 0; + background: #ffffff; + box-shadow: -50px 0px 100px rgba(118, 135, 165, 0.4); + border-radius: 16px 0 0 16px; + z-index: 2; +} + +.heading { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + width: 100%; + padding-bottom: 15px; +} + +.topper { + height: 24px; + width: 100%; +} + +.headerRow { + display: flex; + justify-content: space-between; + align-items: flex-start; + padding: 0 32px; + width: 100%; + box-sizing: border-box; +} + +.modalHeader { + margin: 0; + font-family: "Viga", sans-serif; + font-weight: 400; + font-size: 24px; + line-height: 24px; + letter-spacing: 1.2px; + color: #000000; +} + +.closeButton { + width: 24px; + height: 24px; + background: none; + border: none; + cursor: pointer; + padding: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.tabs { + display: flex; + flex-direction: row; + width: 100%; + height: 32px; + border-bottom: 1px solid #e8e8e8; + box-sizing: border-box; +} + +.tabButton { + display: flex; + justify-content: center; + align-items: center; + flex: 1; + height: 32px; + border: none; + background: none; + cursor: pointer; + border-radius: 4px 4px 0 0; + font-family: "Open Sans", sans-serif; + font-size: 12px; + line-height: 16px; + text-align: center; + box-sizing: border-box; +} + +.tabActive { + font-weight: 700; + color: #1d3a6b; + border-bottom: 2px solid #1d3a6b; +} + +.tabInactive { + font-weight: 400; + color: #676767; + border-bottom: 2px solid transparent; +} + +.content { + display: flex; + flex-direction: column; + align-items: flex-start; + padding: 16px 32px; + gap: 24px; + width: 100%; + flex: 1; + overflow-y: auto; + box-sizing: border-box; +} + +.infoSection { + display: flex; + flex-direction: column; + gap: 16px; + width: 100%; +} + +.infoField { + display: flex; + flex-direction: column; + gap: 2px; + width: 100%; +} + +.fieldLabel { + font-family: "Open Sans", sans-serif; + font-weight: 400; + font-size: 12px; + line-height: 16px; + color: #676767; +} + +.fieldValue { + font-family: "Open Sans", sans-serif; + font-weight: 400; + font-size: 16px; + line-height: 20px; + letter-spacing: 0.5px; + color: #000000; +} + +.editSection { + display: flex; + flex-direction: column; + gap: 12px; + width: 100%; +} + +.editField { + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; +} + +.editLabel { + font-family: "Open Sans", sans-serif; + font-weight: 400; + font-size: 12px; + line-height: 16px; + color: #979797; +} + +.editInput { + box-sizing: border-box; + width: 100%; + padding: 12px 16px; + border: 1px solid #e8e8e8; + border-radius: 8px; + font-family: "Open Sans", sans-serif; + font-weight: 400; + font-size: 16px; + line-height: 20px; + letter-spacing: 0.5px; + color: #141414; + outline: none; +} + +.editInput:focus { + border-color: #1d3a6b; +} + +.tagsSection { + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; +} + +.sectionTitle { + font-family: "Open Sans", sans-serif; + font-weight: 700; + font-size: 16px; + line-height: 20px; + letter-spacing: 0.5px; + color: #000000; +} + +.sectionHeaderWithHint { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; +} + +.sectionHint { + font-family: "Open Sans", sans-serif; + font-weight: 400; + font-size: 12px; + line-height: 16px; + color: #676767; +} + +.tagsRow { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.tag { + display: flex; + justify-content: center; + align-items: center; + padding: 8px 12px; + gap: 8px; + border-radius: 100px; + font-family: "Open Sans", sans-serif; + font-weight: 400; + font-size: 12px; + line-height: 16px; +} + +.tagTeal { + background: #e6f2f3; + color: #007a8a; +} + +.tagOrange { + background: #f9efe6; + color: #c46200; +} + +.tagGreen { + background: #e6f2ec; + color: #007f3f; +} + +.tagRemove { + display: flex; + align-items: center; + justify-content: center; + background: none; + border: none; + cursor: pointer; + padding: 0; + width: 16px; + height: 16px; +} + +.searchAddRow { + display: flex; + flex-direction: row; + gap: 8px; + width: 100%; +} + +.searchAddField { + display: flex; + align-items: center; + box-sizing: border-box; + flex: 1; + padding: 12px 16px; + border: 1px solid #e8e8e8; + border-radius: 8px; + gap: 12px; +} + +.searchAddInput { + flex: 1; + border: none; + outline: none; + font-family: "Open Sans", sans-serif; + font-weight: 400; + font-size: 12px; + line-height: 16px; + color: #141414; +} + +.searchAddInput::placeholder { + color: #676767; +} + +.caretIcon { + width: 24px; + height: 24px; +} + +.addButton { + display: flex; + justify-content: center; + align-items: center; + box-sizing: border-box; + width: 44px; + height: 48px; + border: 1px solid #e8e8e8; + border-radius: 4px; + background: none; + cursor: pointer; + padding: 8px 12px; +} + +.saveError { + font-family: "Open Sans", sans-serif; + font-size: 12px; + line-height: 16px; + color: #a40026; +} + +.saveButton { + display: flex; + justify-content: center; + align-items: center; + padding: 12px 16px; + width: 100%; + border: none; + border-radius: 8px; + background: #1d3a6b; + color: #ffffff; + font-family: "Open Sans", sans-serif; + font-size: 14px; + font-weight: 600; + cursor: pointer; +} + +.saveButton:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +@media (max-width: 768px) { + .backdrop { + display: block; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.35); + z-index: 1; + } + + .modal { + width: 100%; + left: 0; + right: 0; + top: 62px; + bottom: 16px; + padding: 0 0 16px; + border-radius: 16px 16px 0 0; + box-shadow: 0 -20px 60px rgba(118, 135, 165, 0.25); + } + + .heading { + gap: 4px; + padding-bottom: 8px; + } + + .topper { + height: 0; + } + + .headerRow { + padding: 0 16px; + } + + .modalHeader { + font-size: 20px; + line-height: 24px; + letter-spacing: 0.8px; + margin-top: 34px; + } + + .tabs { + height: 36px; + } + + .tabButton { + height: 36px; + font-size: 12px; + } + + .content { + padding: 16px; + gap: 16px; + } + + .infoSection { + gap: 12px; + } + + .fieldValue { + font-size: 14px; + line-height: 18px; + } + + .editSection { + gap: 10px; + } + + .editField { + gap: 6px; + } + + .editInput { + padding: 10px 12px; + font-size: 14px; + } + + .sectionTitle { + font-size: 14px; + line-height: 18px; + } + + .sectionHint { + font-size: 11px; + } + + .tagsRow { + gap: 6px; + } + + .tag { + padding: 6px 10px; + font-size: 11px; + line-height: 14px; + } + + .searchAddRow { + gap: 6px; + } + + .searchAddField { + padding: 10px 12px; + } + + .searchAddInput { + font-size: 12px; + } + + .addButton { + width: 40px; + height: 44px; + } + + .saveButton { + padding: 12px; + font-size: 13px; + } +} diff --git a/frontend/src/components/VolunteerProfileModal.tsx b/frontend/src/components/VolunteerProfileModal.tsx new file mode 100644 index 00000000..2e6e91d3 --- /dev/null +++ b/frontend/src/components/VolunteerProfileModal.tsx @@ -0,0 +1,423 @@ +"use client"; +import { Volunteer } from "../types/volunteer"; +import styles from "./VolunteerProfileModal.module.css"; +import { useEffect, useState } from "react"; + +interface VolunteerProfileModalProps { + volunteer: Volunteer | null; + isOpen: boolean; + onClose: () => void; + onVolunteerUpdated?: (volunteer: Volunteer) => void; +} + +const VOLUNTEER_TYPE_TAGS = ["Intern", "Outside Volunteer"]; +const RETURNING_STATUS_LABELS = new Set(["returner", "returning", "expert"]); + +const getTagColorClass = (tag: string, styles: Record) => { + if (tag === "Outside Volunteer") return styles.tagOrange; + if (tag.includes("More")) return styles.tagGreen; + return styles.tagTeal; +}; + +const getVolunteerTagNames = (volunteer: Volunteer) => volunteer.tags.map((tag) => tag.name); + +const deriveStatus = (volunteer: Volunteer): "new" | "returning" => { + if (volunteer.status === "new" || volunteer.status === "returning") { + return volunteer.status; + } + + const statusCandidates = volunteer.tags.map((tag) => tag.name.toLowerCase()); + return statusCandidates.some((candidate) => RETURNING_STATUS_LABELS.has(candidate)) + ? "returning" + : "new"; +}; + +const formatStatus = (status: "new" | "returning") => + status === "returning" ? "Returning" : "New"; + +export default function VolunteerProfileModal({ + volunteer, + isOpen, + onClose, + onVolunteerUpdated, +}: VolunteerProfileModalProps) { + const [activeTab, setActiveTab] = useState("view"); + const [firstName, setFirstName] = useState(""); + const [lastName, setLastName] = useState(""); + const [email, setEmail] = useState(""); + const [phoneNumber, setPhoneNumber] = useState(""); + const [status, setStatus] = useState<"new" | "returning">("new"); + const [typeTags, setTypeTags] = useState([]); + const [eventTags, setEventTags] = useState([]); + const [typeInput, setTypeInput] = useState(""); + const [eventInput, setEventInput] = useState(""); + const [additionalNotes, setAdditionalNotes] = useState(""); + const [isSaving, setIsSaving] = useState(false); + const [saveError, setSaveError] = useState(""); + + useEffect(() => { + if (!volunteer) return; + setFirstName(volunteer.firstName); + setLastName(volunteer.lastName); + setEmail(volunteer.email); + setPhoneNumber(volunteer.phoneNumber); + + const volunteerTagNames = getVolunteerTagNames(volunteer); + const fallbackTypeTags = volunteerTagNames.filter((tag) => VOLUNTEER_TYPE_TAGS.includes(tag)); + setStatus(deriveStatus(volunteer)); + setTypeTags(volunteer.volunteerTypeTags ?? fallbackTypeTags); + setEventTags(volunteer.events ?? []); + setAdditionalNotes(volunteer.additionalNotes ?? ""); + setTypeInput(""); + setEventInput(""); + setSaveError(""); + }, [volunteer]); + + const handleAddTypeTag = () => { + const value = typeInput.trim(); + if (!value || typeTags.includes(value)) return; + setTypeTags((prev) => [...prev, value]); + setTypeInput(""); + }; + + const handleAddEventTag = () => { + const value = eventInput.trim(); + if (!value || eventTags.includes(value)) return; + setEventTags((prev) => [...prev, value]); + setEventInput(""); + }; + + const handleRemoveTypeTag = (tag: string) => { + setTypeTags((prev) => prev.filter((value) => value !== tag)); + }; + + const handleRemoveEventTag = (tag: string) => { + setEventTags((prev) => prev.filter((value) => value !== tag)); + }; + + const handleSave = async () => { + if (!volunteer) return; + setIsSaving(true); + setSaveError(""); + try { + const response = await fetch(`/api/volunteer/${volunteer._id}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + firstName, + lastName, + email, + phoneNumber, + status, + volunteerTypeTags: typeTags, + events: eventTags, + additionalNotes, + }), + }); + + if (!response.ok) { + const payload = (await response.json()) as { error?: string }; + throw new Error(payload.error || "Failed to update volunteer"); + } + + const updated = (await response.json()) as Volunteer; + onVolunteerUpdated?.(updated); + } catch (error) { + setSaveError(error instanceof Error ? error.message : "Failed to update volunteer"); + } finally { + setIsSaving(false); + } + }; + + if (!isOpen || !volunteer) return null; + + return ( + <> +