diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b1ccf4f..2ac249d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -208,6 +208,35 @@ Please follow these coding style guidelines: - Write clear, concise comments where necessary. - Use meaningful variable and function names. +### Contributing Guidelines +- Create a feature branch from main (e.g., feat/add-postgres-stack). +- Use Conventional Commits (feat:, fix:, chore:) to keep history clean and readable. +- Include tests where applicable. +- Run linting and tests before submitting a pull request. +- Ensure your changes follow the existing project structure and registry-based stack system. + +--- + +## Issue Labels + +When opening issues or reviewing pull requests, please apply the most relevant label(s) to help maintainers triage and prioritize them. Here’s a basic guide: + +| Label | When to Use | +|-------|-------------| +| `bug` | A reproducible error, crash, or incorrect behavior in Taco. | +| `feature` | A new capability or enhancement that does not exist yet. | +| `enhancement` | Improvements to existing features, performance, or developer experience. | +| `docs` | Documentation updates, typos, or README/CONTRIBUTING changes. | +| `refactor` | Code cleanup or restructuring without changing behavior. | +| `ci` | Issues related to GitHub Actions, testing, or build pipelines. | +| `good first issue` | Small, well-scoped issues suitable for new contributors. | + +**Tips:** +- Most issues should have **one primary label**. +- If an issue touches multiple areas (e.g., a feature that also needs docs), feel free to add more than one. +- PRs should ideally match the label of the issue they close. + +--- ### Commit Messages Please write a rough description for the changes made in each commits diff --git a/ISSUE_TEMPLATE/config.yaml b/ISSUE_TEMPLATE/config.yaml new file mode 100644 index 0000000..2e3daf2 --- /dev/null +++ b/ISSUE_TEMPLATE/config.yaml @@ -0,0 +1,2 @@ +# .github/ISSUE_TEMPLATE/config.yml +blank_issues_enabled: false diff --git a/PULL_REQUEST_TEMPLATE.md b/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..95f2ad8 --- /dev/null +++ b/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,30 @@ +## Summary + +Describe the purpose of this pull request. What does it do, and why is it needed? + +Closes #(issue number) if applicable. + +--- + +## Changes Made +- [ ] Added a new feature +- [ ] Updated existing functionality +- [ ] Improved documentation +- [ ] Refactored internal code +- [ ] Fixed a bug + +Briefly summarize the major changes: + +- +- +- + +--- + +## Testing + +How was this change tested? + +- [ ] Manual end-to-end testing +- [ ] Unit Tests +- [ ] CI checks passed diff --git a/backend/package-lock.json b/backend/package-lock.json index 1814cb4..6bd0590 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -9,6 +9,8 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "axios": "^1.13.5", + "cheerio": "^1.2.0", "cors": "^2.8.5", "dotenv": "^16.3.1", "envalid": "^7.3.1", @@ -1762,8 +1764,7 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/available-typed-arrays": { "version": "1.0.5", @@ -1777,6 +1778,31 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/axios": { + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", + "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/axios/node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1860,6 +1886,11 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==" + }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -2012,6 +2043,46 @@ "node": ">=8" } }, + "node_modules/cheerio": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", + "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "encoding-sniffer": "^0.2.1", + "htmlparser2": "^10.1.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^7.19.0", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=20.18.1" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/chokidar": { "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", @@ -2074,7 +2145,6 @@ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", "license": "MIT", - "optional": true, "dependencies": { "delayed-stream": "~1.0.0" }, @@ -2182,6 +2252,32 @@ "node": ">= 8" } }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", @@ -2230,7 +2326,6 @@ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", "license": "MIT", - "optional": true, "engines": { "node": ">=0.4.0" } @@ -2286,6 +2381,57 @@ "node": ">=6.0.0" } }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ] + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dotenv": { "version": "16.3.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.3.1.tgz", @@ -2369,6 +2515,29 @@ "node": ">= 0.8" } }, + "node_modules/encoding-sniffer": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, + "node_modules/encoding-sniffer/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/end-of-stream": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", @@ -2379,6 +2548,17 @@ "once": "^1.4.0" } }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/envalid": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/envalid/-/envalid-7.3.1.tgz", @@ -2474,14 +2654,14 @@ } }, "node_modules/es-set-tostringtag": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.1.tgz", - "integrity": "sha512-g3OMbtlwY3QewlqAiMLI47KywjWZoEytKr8pf6iTC8uJq5bIAH52Z9pnQ8pVL6whrCto53JZDuUIsifGeLorTg==", - "dev": true, + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", "dependencies": { - "get-intrinsic": "^1.1.3", - "has": "^1.0.3", - "has-tostringtag": "^1.0.0" + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" }, "engines": { "node": ">= 0.4" @@ -3263,6 +3443,25 @@ "integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==", "dev": true }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "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", @@ -3721,12 +3920,11 @@ } }, "node_modules/has-tostringtag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz", - "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==", - "dev": true, + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "dependencies": { - "has-symbols": "^1.0.2" + "has-symbols": "^1.0.3" }, "engines": { "node": ">= 0.4" @@ -3764,6 +3962,35 @@ "license": "MIT", "optional": true }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -4797,7 +5024,6 @@ "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": { @@ -5087,6 +5313,17 @@ "node": ">=0.10.0" } }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -5285,6 +5522,51 @@ "node": ">=6" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -5444,6 +5726,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/pstree.remy": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", @@ -6502,6 +6789,14 @@ "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", "dev": true }, + "node_modules/undici": { + "version": "7.22.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz", + "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "6.20.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", @@ -6606,6 +6901,37 @@ "node": ">=0.8.0" } }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "engines": { + "node": ">=18" + } + }, "node_modules/whatwg-url": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", @@ -8012,8 +8338,7 @@ "asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "optional": true + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, "available-typed-arrays": { "version": "1.0.5", @@ -8021,6 +8346,30 @@ "integrity": "sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==", "dev": true }, + "axios": { + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", + "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", + "requires": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + }, + "dependencies": { + "form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + } + } + } + }, "balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -8077,6 +8426,11 @@ } } }, + "boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==" + }, "brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -8185,6 +8539,37 @@ } } }, + "cheerio": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", + "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==", + "requires": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "encoding-sniffer": "^0.2.1", + "htmlparser2": "^10.1.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^7.19.0", + "whatwg-mimetype": "^4.0.0" + } + }, + "cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "requires": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + } + }, "chokidar": { "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", @@ -8228,7 +8613,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "optional": true, "requires": { "delayed-stream": "~1.0.0" } @@ -8310,6 +8694,23 @@ "which": "^2.0.1" } }, + "css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "requires": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + } + }, + "css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==" + }, "debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", @@ -8344,8 +8745,7 @@ "delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "optional": true + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==" }, "depd": { "version": "2.0.0", @@ -8381,6 +8781,39 @@ "esutils": "^2.0.2" } }, + "dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "requires": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + } + }, + "domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==" + }, + "domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "requires": { + "domelementtype": "^2.3.0" + } + }, + "domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "requires": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + } + }, "dotenv": { "version": "16.3.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.3.1.tgz", @@ -8444,6 +8877,25 @@ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==" }, + "encoding-sniffer": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", + "requires": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "dependencies": { + "iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "requires": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + } + } + } + }, "end-of-stream": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", @@ -8453,6 +8905,11 @@ "once": "^1.4.0" } }, + "entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==" + }, "envalid": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/envalid/-/envalid-7.3.1.tgz", @@ -8527,14 +8984,14 @@ } }, "es-set-tostringtag": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.1.tgz", - "integrity": "sha512-g3OMbtlwY3QewlqAiMLI47KywjWZoEytKr8pf6iTC8uJq5bIAH52Z9pnQ8pVL6whrCto53JZDuUIsifGeLorTg==", - "dev": true, + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", "requires": { - "get-intrinsic": "^1.1.3", - "has": "^1.0.3", - "has-tostringtag": "^1.0.0" + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" } }, "es-shim-unscopables": { @@ -9137,6 +9594,11 @@ "integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==", "dev": true }, + "follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==" + }, "for-each": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", @@ -9447,12 +9909,11 @@ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==" }, "has-tostringtag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz", - "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==", - "dev": true, + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "requires": { - "has-symbols": "^1.0.2" + "has-symbols": "^1.0.3" } }, "hasown": { @@ -9469,6 +9930,24 @@ "integrity": "sha512-K//PSRMQk4FZ78Kyau+mZurHn3FH0Vwr+H36eE0rPbeYkRRi9YxceYPhuN60UwWorxyKHhqoAJl2OFKa4BVtaA==", "optional": true }, + "htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "requires": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + }, + "dependencies": { + "entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==" + } + } + }, "http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -10392,6 +10871,14 @@ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", "dev": true }, + "nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "requires": { + "boolbase": "^1.0.0" + } + }, "object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -10527,6 +11014,38 @@ "callsites": "^3.0.0" } }, + "parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "requires": { + "entities": "^6.0.0" + }, + "dependencies": { + "entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==" + } + } + }, + "parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "requires": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + } + }, + "parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "requires": { + "parse5": "^7.0.0" + } + }, "parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -10638,6 +11157,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==" + }, "pstree.remy": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", @@ -11367,6 +11891,11 @@ "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", "dev": true }, + "undici": { + "version": "7.22.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz", + "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==" + }, "undici-types": { "version": "6.20.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", @@ -11437,6 +11966,29 @@ "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==" }, + "whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "requires": { + "iconv-lite": "0.6.3" + }, + "dependencies": { + "iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "requires": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + } + } + } + }, + "whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==" + }, "whatwg-url": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", diff --git a/backend/package.json b/backend/package.json index cfb70bf..5abf57f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,5 +1,7 @@ { "dependencies": { + "axios": "^1.13.5", + "cheerio": "^1.2.0", "cors": "^2.8.5", "dotenv": "^16.3.1", "envalid": "^7.3.1", diff --git a/backend/src/app.ts b/backend/src/app.ts index e8d80d9..c9f2a1b 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -9,6 +9,8 @@ import { isHttpError } from "http-errors"; import productRoutes from "src/routes/product"; import userRoutes from "src/routes/user"; import interestEmailRoute from "src/routes/interestEmail"; +import studentOrganizationRoutes from "src/routes/studentOrganization"; +import merchRoutes from "src/routes/merch"; const app = express(); // initializes Express to accept JSON in the request/response body @@ -28,6 +30,8 @@ app.use( app.use("/api/products", productRoutes); app.use("/api/users", userRoutes); app.use("/api/interestEmail", interestEmailRoute); +app.use("/api/student-organizations", studentOrganizationRoutes); +app.use("/api/merch", merchRoutes); /** * Error handler; all errors thrown by server are handled here. * Explicit typings required here because TypeScript cannot infer the argument types. diff --git a/backend/src/controllers/merch.ts b/backend/src/controllers/merch.ts new file mode 100644 index 0000000..930093b --- /dev/null +++ b/backend/src/controllers/merch.ts @@ -0,0 +1,252 @@ +import { Response } from "express"; +import MerchModel from "src/models/merch"; +import StudentOrganizationModel from "src/models/studentOrganization"; +import { AuthenticatedRequest } from "src/validators/authUserMiddleware"; +import mongoose from "mongoose"; +import { bucket } from "src/config/firebase"; +import { getStorage, ref, getDownloadURL } from "firebase/storage"; +import { v4 as uuidv4 } from "uuid"; +import { initializeApp } from "firebase/app"; +import { firebaseConfig } from "src/config/firebaseConfig"; +import multer from "multer"; + +const upload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB limit +}).single("image"); + +/** + * Get all merch items + */ +export const getAllMerch = async (req: AuthenticatedRequest, res: Response) => { + try { + const merchItems = await MerchModel.find().populate("studentOrganizationId"); + res.status(200).json(merchItems); + } catch (error) { + res.status(500).json({ message: "Error fetching merch items", error }); + } +}; + +/** + * Get merch by ID + */ +export const getMerchById = async (req: AuthenticatedRequest, res: Response) => { + try { + const id = req.params.id; + if (!mongoose.Types.ObjectId.isValid(id)) { + return res.status(400).json({ message: "Invalid ID format" }); + } + const merch = await MerchModel.findById(id).populate("studentOrganizationId"); + if (!merch) { + return res.status(404).json({ message: "Merch item not found" }); + } + res.status(200).json(merch); + } catch (error) { + res.status(500).json({ message: "Error getting merch item", error }); + } +}; + +/** + * Get all merch items for a specific student organization + */ +export const getMerchByOrganization = async (req: AuthenticatedRequest, res: Response) => { + try { + const organizationId = req.params.organizationId; + if (!mongoose.Types.ObjectId.isValid(organizationId)) { + return res.status(400).json({ message: "Invalid organization ID format" }); + } + const merchItems = await MerchModel.find({ studentOrganizationId: organizationId }); + res.status(200).json(merchItems); + } catch (error) { + res.status(500).json({ message: "Error fetching merch items", error }); + } +}; + +/** + * Get all merch items for the authenticated user's organization + */ +export const getMyOrganizationMerch = async (req: AuthenticatedRequest, res: Response) => { + try { + if (!req.user) return res.status(404).json({ message: "User not found" }); + + const firebaseUid = req.user.firebaseUid; + const organization = await StudentOrganizationModel.findOne({ firebaseUid }); + + if (!organization) { + return res.status(404).json({ message: "Student organization not found" }); + } + + const merchItems = await MerchModel.find({ studentOrganizationId: organization._id }); + res.status(200).json(merchItems); + } catch (error) { + res.status(500).json({ message: "Error fetching merch items", error }); + } +}; + +/** + * Add merch item to a student organization + */ +export const addMerch = [ + upload, + async (req: AuthenticatedRequest, res: Response) => { + try { + const { name, price, description } = req.body; + if (!req.user) return res.status(404).json({ message: "User not found" }); + + const firebaseUid = req.user.firebaseUid; + const organization = await StudentOrganizationModel.findOne({ firebaseUid }); + + if (!organization) { + return res.status(404).json({ message: "Student organization not found" }); + } + + if (!name || !price) { + return res.status(400).json({ message: "Name and price are required." }); + } + + let imageUrl = ""; + if (req.file) { + const app = initializeApp(firebaseConfig); + const storage = getStorage(app); + const fileName = `${uuidv4()}-${req.file.originalname}`; + const firebaseFile = bucket.file(fileName); + + await firebaseFile.save(req.file.buffer, { + metadata: { contentType: req.file.mimetype }, + }); + + imageUrl = await getDownloadURL(ref(storage, fileName)); + } + + const newMerch = new MerchModel({ + name, + price: parseFloat(price), + description: description || "", + image: imageUrl, + studentOrganizationId: organization._id, + timeCreated: new Date(), + timeUpdated: new Date(), + }); + + const savedMerch = await newMerch.save(); + res.status(201).json(savedMerch); + } catch (error) { + res.status(500).json({ message: "Error adding merch item", error }); + } + }, +]; + +/** + * Update merch item + */ +export const updateMerch = [ + upload, + async (req: AuthenticatedRequest, res: Response) => { + try { + const id = req.params.id; + if (!req.user) return res.status(404).json({ message: "User not found" }); + if (!mongoose.Types.ObjectId.isValid(id)) { + return res.status(400).json({ message: "Invalid ID format" }); + } + + const firebaseUid = req.user.firebaseUid; + const organization = await StudentOrganizationModel.findOne({ firebaseUid }); + + if (!organization) { + return res.status(404).json({ message: "Student organization not found" }); + } + + const merch = await MerchModel.findById(id); + if (!merch) { + return res.status(404).json({ message: "Merch item not found" }); + } + + // Verify the merch belongs to the user's organization + if (merch.studentOrganizationId.toString() !== organization._id.toString()) { + return res.status(403).json({ message: "You don't have permission to edit this merch item" }); + } + + const { name, price, description, existingImage } = req.body; + + let imageUrl = existingImage || merch.image; + if (req.file) { + const app = initializeApp(firebaseConfig); + const storage = getStorage(app); + const fileName = `${uuidv4()}-${req.file.originalname}`; + const firebaseFile = bucket.file(fileName); + + await firebaseFile.save(req.file.buffer, { + metadata: { contentType: req.file.mimetype }, + }); + + imageUrl = await getDownloadURL(ref(storage, fileName)); + } + + const updatedMerch = await MerchModel.findByIdAndUpdate( + id, + { + name: name || merch.name, + price: price !== undefined ? parseFloat(price) : merch.price, + description: description !== undefined ? description : merch.description, + image: imageUrl, + timeUpdated: new Date(), + }, + { new: true }, + ); + + if (!updatedMerch) { + return res.status(404).json({ message: "Merch item not found" }); + } + + res.status(200).json({ + message: "Merch item successfully updated", + merch: updatedMerch, + }); + } catch (error) { + res.status(500).json({ message: "Error updating merch item", error }); + } + }, +]; + +/** + * Delete merch item + */ +export const deleteMerch = async (req: AuthenticatedRequest, res: Response) => { + try { + const id = req.params.id; + if (!req.user) return res.status(404).json({ message: "User not found" }); + if (!mongoose.Types.ObjectId.isValid(id)) { + return res.status(400).json({ message: "Invalid ID format" }); + } + + const firebaseUid = req.user.firebaseUid; + const organization = await StudentOrganizationModel.findOne({ firebaseUid }); + + if (!organization) { + return res.status(404).json({ message: "Student organization not found" }); + } + + const merch = await MerchModel.findById(id); + if (!merch) { + return res.status(404).json({ message: "Merch item not found" }); + } + + // Verify the merch belongs to the user's organization + if (merch.studentOrganizationId.toString() !== organization._id.toString()) { + return res.status(403).json({ message: "You don't have permission to delete this merch item" }); + } + + const deletedMerch = await MerchModel.findByIdAndDelete(id); + if (!deletedMerch) { + return res.status(404).json({ message: "Merch item not found" }); + } + + res.status(200).json({ + message: "Merch item successfully deleted", + merch: deletedMerch, + }); + } catch (error) { + res.status(500).json({ message: "Error deleting merch item", error }); + } +}; + diff --git a/backend/src/controllers/studentOrganizations.ts b/backend/src/controllers/studentOrganizations.ts new file mode 100644 index 0000000..12282f7 --- /dev/null +++ b/backend/src/controllers/studentOrganizations.ts @@ -0,0 +1,251 @@ +import { Response } from "express"; +import StudentOrganizationModel from "src/models/studentOrganization"; +import { AuthenticatedRequest } from "src/validators/authUserMiddleware"; +import { hasStudentOrgAccess } from "src/validators/studentOrgAccess"; +import mongoose from "mongoose"; +import { bucket } from "src/config/firebase"; +import { getStorage, ref, getDownloadURL } from "firebase/storage"; +import { v4 as uuidv4 } from "uuid"; +import { initializeApp } from "firebase/app"; +import { firebaseConfig } from "src/config/firebaseConfig"; +import multer from "multer"; + +const upload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB limit +}).single("profilePicture"); + +/** + * Check whether the authenticated user can access "My Organization" (allowed email list). + */ +export const getStudentOrgCanAccess = async (req: AuthenticatedRequest, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ message: "Authentication required." }); + } + const canAccess = hasStudentOrgAccess(req.user.userEmail || ""); + res.status(200).json({ canAccess }); + } catch (error) { + res.status(500).json({ message: "Error checking access", error }); + } +}; + +/** + * Get all student organizations + */ +export const getStudentOrganizations = async (req: AuthenticatedRequest, res: Response) => { + try { + const organizations = await StudentOrganizationModel.find(); + res.status(200).json(organizations); + } catch (error) { + res.status(500).json({ message: "Error fetching student organizations", error }); + } +}; + +/** + * Get student organization by ID + */ +export const getStudentOrganizationById = async (req: AuthenticatedRequest, res: Response) => { + try { + const id = req.params.id; + if (!mongoose.Types.ObjectId.isValid(id)) { + return res.status(400).json({ message: "Invalid ID format" }); + } + const organization = await StudentOrganizationModel.findById(id); + if (!organization) { + return res.status(404).json({ message: "Student organization not found" }); + } + res.status(200).json(organization); + } catch (error) { + res.status(500).json({ message: "Error getting student organization", error }); + } +}; + +/** + * Get student organization by Firebase UID + */ +export const getStudentOrganizationByFirebaseUid = async ( + req: AuthenticatedRequest, + res: Response, +) => { + try { + const firebaseUid = req.params.firebaseUid; + const organization = await StudentOrganizationModel.findOne({ firebaseUid }); + if (!organization) { + return res.status(404).json({ message: "Student organization not found" }); + } + res.status(200).json(organization); + } catch (error) { + res.status(500).json({ message: "Error getting student organization", error }); + } +}; + +/** + * Create a new student organization profile + */ +export const createStudentOrganization = [ + upload, + async (req: AuthenticatedRequest, res: Response) => { + try { + if (!req.user) return res.status(404).json({ message: "User not found" }); + + const { organizationName, bio, location, contactInfo, merchLocation } = req.body; + const firebaseUid = req.user.firebaseUid; + + if (!organizationName) { + return res.status(400).json({ message: "Organization name is required." }); + } + + // Check if organization already exists for this user + const existingOrg = await StudentOrganizationModel.findOne({ firebaseUid }); + if (existingOrg) { + return res.status(409).json({ + message: "Student organization profile already exists for this user", + organization: existingOrg, + }); + } + + let profilePictureUrl = ""; + if (req.file) { + const app = initializeApp(firebaseConfig); + const storage = getStorage(app); + const fileName = `${uuidv4()}-${req.file.originalname}`; + const firebaseFile = bucket.file(fileName); + + await firebaseFile.save(req.file.buffer, { + metadata: { contentType: req.file.mimetype }, + }); + + profilePictureUrl = await getDownloadURL(ref(storage, fileName)); + } + + // Parse contactInfo if it's a string + let parsedContactInfo = { + email: "", + instagram: "", + website: "", + other: "", + }; + if (contactInfo) { + if (typeof contactInfo === "string") { + parsedContactInfo = JSON.parse(contactInfo); + } else { + parsedContactInfo = contactInfo; + } + } + + const newOrganization = new StudentOrganizationModel({ + organizationName, + profilePicture: profilePictureUrl, + bio: bio || "", + location: location || "", + contactInfo: parsedContactInfo, + merchLocation: merchLocation || "", + firebaseUid, + timeCreated: new Date(), + timeUpdated: new Date(), + }); + + const savedOrganization = await newOrganization.save(); + res.status(201).json(savedOrganization); + } catch (error) { + res.status(500).json({ message: "Error creating student organization", error }); + } + }, +]; + +/** + * Update student organization profile + */ +export const updateStudentOrganization = [ + upload, + async (req: AuthenticatedRequest, res: Response) => { + try { + if (!req.user) return res.status(404).json({ message: "User not found" }); + + const firebaseUid = req.user.firebaseUid; + const organization = await StudentOrganizationModel.findOne({ firebaseUid }); + + if (!organization) { + return res.status(404).json({ message: "Student organization not found" }); + } + + const { organizationName, bio, location, contactInfo, merchLocation, existingProfilePicture } = + req.body; + + // Handle profile picture upload + let profilePictureUrl = existingProfilePicture || organization.profilePicture; + if (req.file) { + const app = initializeApp(firebaseConfig); + const storage = getStorage(app); + const fileName = `${uuidv4()}-${req.file.originalname}`; + const firebaseFile = bucket.file(fileName); + + await firebaseFile.save(req.file.buffer, { + metadata: { contentType: req.file.mimetype }, + }); + + profilePictureUrl = await getDownloadURL(ref(storage, fileName)); + } + + // Parse contactInfo if it's a string + let parsedContactInfo = organization.contactInfo; + if (contactInfo) { + if (typeof contactInfo === "string") { + parsedContactInfo = JSON.parse(contactInfo); + } else { + parsedContactInfo = contactInfo; + } + } + + const updatedOrganization = await StudentOrganizationModel.findOneAndUpdate( + { firebaseUid }, + { + organizationName: organizationName || organization.organizationName, + profilePicture: profilePictureUrl, + bio: bio !== undefined ? bio : organization.bio, + location: location !== undefined ? location : organization.location, + contactInfo: parsedContactInfo, + merchLocation: merchLocation !== undefined ? merchLocation : organization.merchLocation, + timeUpdated: new Date(), + }, + { new: true }, + ); + + if (!updatedOrganization) { + return res.status(404).json({ message: "Student organization not found" }); + } + + res.status(200).json({ + message: "Student organization successfully updated", + organization: updatedOrganization, + }); + } catch (error) { + res.status(500).json({ message: "Error updating student organization", error }); + } + }, +]; + +/** + * Delete student organization profile + */ +export const deleteStudentOrganization = async (req: AuthenticatedRequest, res: Response) => { + try { + if (!req.user) return res.status(404).json({ message: "User not found" }); + + const firebaseUid = req.user.firebaseUid; + const organization = await StudentOrganizationModel.findOneAndDelete({ firebaseUid }); + + if (!organization) { + return res.status(404).json({ message: "Student organization not found" }); + } + + res.status(200).json({ + message: "Student organization successfully deleted", + organization, + }); + } catch (error) { + res.status(500).json({ message: "Error deleting student organization", error }); + } +}; + diff --git a/backend/src/models/merch.ts b/backend/src/models/merch.ts new file mode 100644 index 0000000..d42c1ce --- /dev/null +++ b/backend/src/models/merch.ts @@ -0,0 +1,40 @@ +import { HydratedDocument, InferSchemaType, Schema, model } from "mongoose"; + +const merchSchema = new Schema({ + name: { + type: String, + required: true, + }, + price: { + type: Number, + required: true, + }, + description: { + type: String, + default: "", + }, + image: { + type: String, + default: "", + }, + studentOrganizationId: { + type: Schema.Types.ObjectId, + ref: "StudentOrganization", + required: true, + }, + timeCreated: { + type: Date, + required: true, + default: Date.now, + }, + timeUpdated: { + type: Date, + required: true, + default: Date.now, + }, +}); + +export type Merch = HydratedDocument>; + +export default model("Merch", merchSchema); + diff --git a/backend/src/models/studentOrganization.ts b/backend/src/models/studentOrganization.ts new file mode 100644 index 0000000..45cd6c0 --- /dev/null +++ b/backend/src/models/studentOrganization.ts @@ -0,0 +1,62 @@ +import { HydratedDocument, InferSchemaType, Schema, model } from "mongoose"; + +const studentOrganizationSchema = new Schema({ + organizationName: { + type: String, + required: true, + }, + profilePicture: { + type: String, + default: "", + }, + bio: { + type: String, + default: "", + }, + location: { + type: String, + default: "", + }, + contactInfo: { + email: { + type: String, + default: "", + }, + instagram: { + type: String, + default: "", + }, + website: { + type: String, + default: "", + }, + other: { + type: String, + default: "", + }, + }, + merchLocation: { + type: String, + default: "", + }, + firebaseUid: { + type: String, + required: true, + unique: true, + }, + timeCreated: { + type: Date, + required: true, + default: Date.now, + }, + timeUpdated: { + type: Date, + required: true, + default: Date.now, + }, +}); + +export type StudentOrganization = HydratedDocument>; + +export default model("StudentOrganization", studentOrganizationSchema); + diff --git a/backend/src/routes/merch.ts b/backend/src/routes/merch.ts new file mode 100644 index 0000000..ae01f8d --- /dev/null +++ b/backend/src/routes/merch.ts @@ -0,0 +1,30 @@ +import express from "express"; +import { + getAllMerch, + getMerchById, + getMerchByOrganization, + getMyOrganizationMerch, + addMerch, + updateMerch, + deleteMerch, +} from "src/controllers/merch"; +import { authenticateUser } from "src/validators/authUserMiddleware"; +import { requireStudentOrgAccess } from "src/validators/studentOrgAccess"; + +const router = express.Router(); + +router.get("/", authenticateUser, getAllMerch); +router.get( + "/my-organization", + authenticateUser, + requireStudentOrgAccess, + getMyOrganizationMerch, +); +router.get("/organization/:organizationId", authenticateUser, getMerchByOrganization); +router.get("/:id", authenticateUser, getMerchById); +router.post("/", authenticateUser, requireStudentOrgAccess, addMerch); +router.patch("/:id", authenticateUser, requireStudentOrgAccess, updateMerch); +router.delete("/:id", authenticateUser, requireStudentOrgAccess, deleteMerch); + +export default router; + diff --git a/backend/src/routes/studentOrganization.ts b/backend/src/routes/studentOrganization.ts new file mode 100644 index 0000000..bfa843a --- /dev/null +++ b/backend/src/routes/studentOrganization.ts @@ -0,0 +1,30 @@ +import express from "express"; +import { + getStudentOrganizations, + getStudentOrganizationById, + getStudentOrganizationByFirebaseUid, + getStudentOrgCanAccess, + createStudentOrganization, + updateStudentOrganization, + deleteStudentOrganization, +} from "src/controllers/studentOrganizations"; +import { authenticateUser } from "src/validators/authUserMiddleware"; +import { requireStudentOrgAccess } from "src/validators/studentOrgAccess"; + +const router = express.Router(); + +router.get("/", authenticateUser, getStudentOrganizations); +router.get("/can-access", authenticateUser, getStudentOrgCanAccess); +router.get( + "/firebase/:firebaseUid", + authenticateUser, + requireStudentOrgAccess, + getStudentOrganizationByFirebaseUid, +); +router.get("/:id", authenticateUser, getStudentOrganizationById); +router.post("/", authenticateUser, requireStudentOrgAccess, createStudentOrganization); +router.patch("/", authenticateUser, requireStudentOrgAccess, updateStudentOrganization); +router.delete("/", authenticateUser, requireStudentOrgAccess, deleteStudentOrganization); + +export default router; + diff --git a/backend/src/util/validateEnv.ts b/backend/src/util/validateEnv.ts index 2bfc9b3..0a52009 100644 --- a/backend/src/util/validateEnv.ts +++ b/backend/src/util/validateEnv.ts @@ -11,4 +11,6 @@ export default cleanEnv(process.env, { MONGODB_URI: str(), FIREBASE_PRIVATE_KEY_BASE64: str(), FIREBASE_PROJECT_ID: str(), + /** Comma-separated list of emails allowed to create/manage student organizations. Empty = no one. */ + STUDENT_ORG_ALLOWED_EMAILS: str({ default: "" }), }); diff --git a/backend/src/validators/studentOrgAccess.ts b/backend/src/validators/studentOrgAccess.ts new file mode 100644 index 0000000..4320a51 --- /dev/null +++ b/backend/src/validators/studentOrgAccess.ts @@ -0,0 +1,66 @@ +import { Response, NextFunction } from "express"; +import env from "src/util/validateEnv"; +import { AuthenticatedRequest } from "src/validators/authUserMiddleware"; + +/** + * Emails that can have their own "My Organization" profile and manage merch. + * Only these users can create/edit their org and add/edit/delete merch. + * Leave empty [] to use STUDENT_ORG_ALLOWED_EMAILS from .env instead. + * + * Add allowed emails here, e.g.: + * "mik127@ucsd.edu", + * "another-org@ucsd.edu", + */ +const ALLOWED_ORGANIZATION_EMAILS: string[] = [ + // "mik127@ucsd.edu", +]; + +function allowedEmailsSet(): Set { + const fromCode = ALLOWED_ORGANIZATION_EMAILS.map((e) => e.trim().toLowerCase()).filter(Boolean); + if (fromCode.length > 0) { + return new Set(fromCode); + } + const raw = env.STUDENT_ORG_ALLOWED_EMAILS || ""; + if (!raw.trim()) return new Set(); + return new Set( + raw + .split(",") + .map((e) => e.trim().toLowerCase()) + .filter(Boolean), + ); +} + +/** Returns whether the given email can have "My Organization" access. */ +export function hasStudentOrgAccess(email: string): boolean { + const normalized = (email || "").trim().toLowerCase(); + const allowed = allowedEmailsSet(); + return allowed.size > 0 && allowed.has(normalized); +} + +/** + * Middleware that restricts "My Organization" to allowed emails only. + * Allowed list: ALLOWED_ORGANIZATION_EMAILS in this file (if non-empty), + * otherwise STUDENT_ORG_ALLOWED_EMAILS in .env. Use after authenticateUser. + */ +export const requireStudentOrgAccess = ( + req: AuthenticatedRequest, + res: Response, + next: NextFunction, +) => { + if (!req.user) { + return res.status(401).json({ message: "Authentication required." }); + } + const email = (req.user.userEmail || "").trim().toLowerCase(); + const allowed = allowedEmailsSet(); + if (!allowed.size) { + return res.status(403).json({ + message: "Student organization access is not enabled for any accounts.", + }); + } + if (!allowed.has(email)) { + return res.status(403).json({ + message: "You do not have access to student organization features.", + }); + } + next(); +}; diff --git a/frontend/.env.development b/frontend/.env.development new file mode 100644 index 0000000..375ff15 --- /dev/null +++ b/frontend/.env.development @@ -0,0 +1,3 @@ +# Don't stop the React webpack build if there are lint errors. +ESLINT_NO_DEV_ERRORS=true +VITE_API_BASE_URL=http://localhost:5001 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3b12d45..dd39c96 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,6 +8,17 @@ import { AddProduct } from "src/pages/AddProduct"; import { EditProduct } from "src/pages/EditProduct"; import { IndividualProductPage } from "src/pages/Individual-product-page"; import { Marketplace } from "src/pages/Marketplace"; + +import { PrivateRoute } from "../src/components/PrivateRoute"; +import { AddProduct } from "../src/pages/AddProduct"; +import { EditProduct } from "../src/pages/EditProduct"; +import { IndividualProductPage } from "../src/pages/Individual-product-page"; +import { PageNotFound } from "../src/pages/PageNotFound"; +import FirebaseProvider from "../src/utils/FirebaseProvider"; +import { SavedProducts } from "./pages/SavedProducts"; +import { StudentOrgProfile } from "./pages/StudentOrgProfile"; +import { StudentOrganizations } from "./pages/StudentOrganizations"; +import { StudentOrganizationPublicProfile } from "./pages/StudentOrganizationPublicProfile"; import { PageNotFound } from "src/pages/PageNotFound"; import { SavedProducts } from "src/pages/SavedProducts"; import FirebaseProvider from "src/utils/FirebaseProvider"; @@ -15,6 +26,75 @@ import FirebaseProvider from "src/utils/FirebaseProvider"; const router = createBrowserRouter([ { path: "/", + element: , + }, + { + path: "/products", + element: ( + + + + ), + }, + { + path: "/add-product", + element: ( + + + + ), + }, + { + path: "/edit-product/:id", + element: ( + + + + ), + }, + { + path: "/products/:id", + element: ( + + + + ), + }, + { + path: "/saved-products", + element: ( + + + + ), + }, + { + path: "/student-org-profile", + element: ( + + + + ), + }, + { + path: "/student-organizations", + element: ( + + + + ), + }, + { + path: "/student-organizations/:id", + element: ( + + + + ), + }, + { + path: "*", + element: , element: , children: [ { diff --git a/frontend/src/api/requests.ts b/frontend/src/api/requests.ts index befaa9c..0475c11 100644 --- a/frontend/src/api/requests.ts +++ b/frontend/src/api/requests.ts @@ -19,6 +19,7 @@ type Method = "GET" | "POST" | "PUT" | "DELETE" | "PATCH"; * in Vite projects. */ const API_BASE_URL = import.meta.env.VITE_API_BASE_URL; +console.log("API_BASE_URL:", API_BASE_URL); /** * A wrapper around the built-in `fetch()` function that abstracts away some of diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx index ef44292..723dc8e 100644 --- a/frontend/src/components/Navbar.tsx +++ b/frontend/src/components/Navbar.tsx @@ -1,3 +1,19 @@ +<<<<<<< HEAD +import { + faBars, + faCartShopping, + faUser, + faXmark, + faHeart, + faUsers, + faStore, + faChevronDown, + faMagnifyingGlass, +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useContext, useEffect, useRef, useState } from "react"; +import { get } from "src/api/requests"; +======= import { faHeart } from "@fortawesome/free-regular-svg-icons"; import { faBars, @@ -9,6 +25,7 @@ import { import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { HTMLAttributes, forwardRef, useContext, useEffect, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; +>>>>>>> upstream/dev import { FirebaseContext } from "src/utils/FirebaseProvider"; interface MiniSearchbarProps extends HTMLAttributes { @@ -54,11 +71,30 @@ MiniSearchbar.displayName = "MiniSearchbar"; export function Navbar() { const { user, signOutFromFirebase, openGoogleAuthentication } = useContext(FirebaseContext); const [isMobileMenuOpen, setMobileMenuOpen] = useState(false); +<<<<<<< HEAD + const [isStudentOrgDropdownOpen, setStudentOrgDropdownOpen] = useState(false); + const [canAccessMyOrganization, setCanAccessMyOrganization] = useState(false); + const menuRef = useRef(null); + const buttonRef = useRef(null); + const dropdownRef = useRef(null); + + useEffect(() => { + if (!user) { + setCanAccessMyOrganization(false); + return; + } + get("/api/student-organizations/can-access") + .then((res) => res.json()) + .then((data: { canAccess?: boolean }) => setCanAccessMyOrganization(data.canAccess === true)) + .catch(() => setCanAccessMyOrganization(false)); + }, [user]); +======= const [isSearchBarOpen, setSearchbarOpen] = useState(false); const menuRef = useRef(null); const buttonRef = useRef(null); const searchRef = useRef(null); const navigate = useNavigate(); +>>>>>>> upstream/dev const toggleMobileMenu = () => setMobileMenuOpen((o) => !o); @@ -82,6 +118,16 @@ export function Navbar() { ) { setMobileMenuOpen(false); } +<<<<<<< HEAD + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) + ) { + setStudentOrgDropdownOpen(false); + } + }; +======= +>>>>>>> upstream/dev if (searchRef.current && !searchRef.current.contains(event.target as Node)) { setSearchbarOpen(false); @@ -106,6 +152,31 @@ export function Navbar() { return ( <> +<<<<<<< HEAD + diff --git a/frontend/src/pages/AddProduct.tsx b/frontend/src/pages/AddProduct.tsx index 87cdc66..019ce70 100644 --- a/frontend/src/pages/AddProduct.tsx +++ b/frontend/src/pages/AddProduct.tsx @@ -74,6 +74,7 @@ export function AddProduct() { setIsSubmitting(true); e.preventDefault(); try { + if (productName.current && productPrice.current && productDescription.current && user) { if (productName.current && productPrice.current && productDescription.current && productYear.current && productCategory.current && productCondition.current && user) { let images; if (productImages.current && productImages.current.files) { diff --git a/frontend/src/pages/Individual-product-page.tsx b/frontend/src/pages/Individual-product-page.tsx index 53e430d..1e0f51a 100644 --- a/frontend/src/pages/Individual-product-page.tsx +++ b/frontend/src/pages/Individual-product-page.tsx @@ -157,7 +157,7 @@ export function IndividualProductPage() { const totalMinutes = Math.ceil(msLeft / (1000 * 60)); // convert ms → minutes const hoursLeft = Math.floor(totalMinutes / 60); const minutesLeft = totalMinutes % 60; - const [tick, setTick] = useState(0); + const [, setTick] = useState(0); useEffect(() => { if (!isCooling) return; const iv = setInterval(() => setTick((t) => t + 1), 60_000); // 60 000 ms = 1 min diff --git a/frontend/src/pages/StudentOrgProfile.tsx b/frontend/src/pages/StudentOrgProfile.tsx new file mode 100644 index 0000000..75691ce --- /dev/null +++ b/frontend/src/pages/StudentOrgProfile.tsx @@ -0,0 +1,959 @@ +import { FormEvent, useContext, useEffect, useRef, useState } from "react"; +import { Helmet } from "react-helmet-async"; +import { get, post, patch, DELETE } from "src/api/requests"; +import { FirebaseContext } from "src/utils/FirebaseProvider"; +import { faStar } from "@fortawesome/free-regular-svg-icons"; +import { faStar as faStarSolid } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +interface StudentOrganization { + _id: string; + organizationName: string; + profilePicture: string; + bio: string; + location: string; + contactInfo: { + email: string; + instagram: string; + website: string; + other: string; + }; + merchLocation: string; + firebaseUid: string; +} + +interface MerchItem { + _id: string; + name: string; + price: number; + description: string; + image: string; + studentOrganizationId: string; +} + + +export function StudentOrgProfile() { + const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5 MB limit + + const { user } = useContext(FirebaseContext); + const [organization, setOrganization] = useState(null); + const [canAccessMyOrg, setCanAccessMyOrg] = useState(null); + const [isEditing, setIsEditing] = useState(false); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [fileError, setFileError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const [activeTab, setActiveTab] = useState<"selling" | "likes" | "saves">("selling"); + + const organizationNameRef = useRef(null); + const bioRef = useRef(null); + const locationRef = useRef(null); + const emailRef = useRef(null); + const instagramRef = useRef(null); + const websiteRef = useRef(null); + const otherContactRef = useRef(null); + const merchLocationRef = useRef(null); + const profilePictureRef = useRef(null); + + const [profilePicturePreview, setProfilePicturePreview] = useState(""); + const [newProfilePicture, setNewProfilePicture] = useState(null); + + // Merch management state + const [merchItems, setMerchItems] = useState([]); + const [isAddingMerch, setIsAddingMerch] = useState(false); + const [editingMerchId, setEditingMerchId] = useState(null); + const [merchError, setMerchError] = useState(""); + const [showEditModal, setShowEditModal] = useState(false); + + const merchNameRef = useRef(null); + const merchPriceRef = useRef(null); + const merchDescriptionRef = useRef(null); + const merchImageRef = useRef(null); + const [merchImagePreview, setMerchImagePreview] = useState(""); + const [newMerchImage, setNewMerchImage] = useState(null); + + useEffect(() => { + const checkAccessAndFetch = async () => { + if (!user?.uid) { + setCanAccessMyOrg(false); + setLoading(false); + return; + } + + try { + setLoading(true); + const accessRes = await get("/api/student-organizations/can-access"); + const accessData = await accessRes.json(); + if (!accessData.canAccess) { + setCanAccessMyOrg(false); + setLoading(false); + return; + } + setCanAccessMyOrg(true); + + const res = await get(`/api/student-organizations/firebase/${user.uid}`); + if (res.ok) { + const data = await res.json(); + setOrganization(data); + setProfilePicturePreview(data.profilePicture || ""); + } else if (res.status === 404) { + setOrganization(null); + } else { + setError("Failed to load organization profile"); + } + } catch (err) { + setCanAccessMyOrg((prev) => (prev === true ? true : false)); + setOrganization(null); + } finally { + setLoading(false); + } + }; + + checkAccessAndFetch(); + }, [user]); + + useEffect(() => { + const fetchMerch = async () => { + if (!organization) { + setMerchItems([]); + return; + } + + try { + const res = await get("/api/merch/my-organization"); + if (res.ok) { + const data = await res.json(); + setMerchItems(data); + } + } catch (err) { + console.error("Failed to fetch merch items:", err); + } + }; + + fetchMerch(); + }, [organization]); + + const handleProfilePictureChange = (e: React.ChangeEvent) => { + if (!e.target.files || !e.target.files[0]) return; + + const file = e.target.files[0]; + if (file.size > MAX_FILE_SIZE) { + setFileError("File size must be less than 5 MB"); + return; + } + + setFileError(null); + setNewProfilePicture(file); + setProfilePicturePreview(URL.createObjectURL(file)); + }; + + const handleCreate = async (e: FormEvent) => { + e.preventDefault(); + if (isSubmitting) return; + setIsSubmitting(true); + setError(""); + + try { + if (!organizationNameRef.current?.value || !user?.uid) { + setError("Organization name is required"); + setIsSubmitting(false); + return; + } + + const body = new FormData(); + body.append("organizationName", organizationNameRef.current.value); + body.append("bio", bioRef.current?.value || ""); + body.append("location", locationRef.current?.value || ""); + + const contactInfo = { + email: emailRef.current?.value || "", + instagram: instagramRef.current?.value || "", + website: websiteRef.current?.value || "", + other: otherContactRef.current?.value || "", + }; + body.append("contactInfo", JSON.stringify(contactInfo)); + body.append("merchLocation", merchLocationRef.current?.value || ""); + + if (newProfilePicture) { + body.append("profilePicture", newProfilePicture); + } + + const res = await post("/api/student-organizations", body); + if (res.ok) { + const data = await res.json(); + setOrganization(data); + setIsEditing(false); + setError(""); + } else { + const errorData = await res.json(); + setError(errorData.message || "Failed to create organization profile"); + } + } catch (err) { + setError("Failed to create organization profile. Please try again."); + } finally { + setIsSubmitting(false); + } + }; + + const handleUpdate = async (e: FormEvent) => { + e.preventDefault(); + if (isSubmitting) return; + setIsSubmitting(true); + setError(""); + + try { + if (!organizationNameRef.current?.value || !user?.uid) { + setError("Organization name is required"); + setIsSubmitting(false); + return; + } + + const body = new FormData(); + body.append("organizationName", organizationNameRef.current.value); + body.append("bio", bioRef.current?.value || ""); + body.append("location", locationRef.current?.value || ""); + + const contactInfo = { + email: emailRef.current?.value || "", + instagram: instagramRef.current?.value || "", + website: websiteRef.current?.value || "", + other: otherContactRef.current?.value || "", + }; + body.append("contactInfo", JSON.stringify(contactInfo)); + body.append("merchLocation", merchLocationRef.current?.value || ""); + + if (newProfilePicture) { + body.append("profilePicture", newProfilePicture); + } else if (organization?.profilePicture) { + body.append("existingProfilePicture", organization.profilePicture); + } + + const res = await patch("/api/student-organizations", body); + if (res.ok) { + const data = await res.json(); + setOrganization(data.organization); + setIsEditing(false); + setError(""); + setNewProfilePicture(null); + } else { + const errorData = await res.json(); + setError(errorData.message || "Failed to update organization profile"); + } + } catch (err) { + setError("Failed to update organization profile. Please try again."); + } finally { + setIsSubmitting(false); + } + }; + + const handleCancel = () => { + setIsEditing(false); + setNewProfilePicture(null); + setProfilePicturePreview(organization?.profilePicture || ""); + setFileError(null); + }; + + // Merch management functions + const handleMerchImageChange = (e: React.ChangeEvent) => { + if (!e.target.files || !e.target.files[0]) return; + + const file = e.target.files[0]; + if (file.size > MAX_FILE_SIZE) { + setMerchError("File size must be less than 5 MB"); + return; + } + + setMerchError(""); + setNewMerchImage(file); + setMerchImagePreview(URL.createObjectURL(file)); + }; + + const handleAddMerch = async (e: FormEvent) => { + e.preventDefault(); + setMerchError(""); + + try { + if (!merchNameRef.current?.value || !merchPriceRef.current?.value) { + setMerchError("Name and price are required"); + return; + } + + const body = new FormData(); + body.append("name", merchNameRef.current.value); + body.append("price", merchPriceRef.current.value); + body.append("description", merchDescriptionRef.current?.value || ""); + + if (newMerchImage) { + body.append("image", newMerchImage); + } + + const res = await post("/api/merch", body); + if (res.ok) { + const data = await res.json(); + setMerchItems([...merchItems, data]); + setIsAddingMerch(false); + setNewMerchImage(null); + setMerchImagePreview(""); + if (merchNameRef.current) merchNameRef.current.value = ""; + if (merchPriceRef.current) merchPriceRef.current.value = ""; + if (merchDescriptionRef.current) merchDescriptionRef.current.value = ""; + if (merchImageRef.current) merchImageRef.current.value = ""; + } else { + const errorData = await res.json(); + setMerchError(errorData.message || "Failed to add merch item"); + } + } catch (err) { + setMerchError("Failed to add merch item. Please try again."); + } + }; + + const handleUpdateMerch = async (e: FormEvent, merchId: string) => { + e.preventDefault(); + setMerchError(""); + + try { + const merch = merchItems.find((m) => m._id === merchId); + if (!merch) return; + + const body = new FormData(); + body.append("name", merchNameRef.current?.value || merch.name); + body.append("price", merchPriceRef.current?.value || merch.price.toString()); + body.append("description", merchDescriptionRef.current?.value || merch.description); + + if (newMerchImage) { + body.append("image", newMerchImage); + } else { + body.append("existingImage", merch.image); + } + + const res = await patch(`/api/merch/${merchId}`, body); + if (res.ok) { + const data = await res.json(); + setMerchItems(merchItems.map((m) => (m._id === merchId ? data.merch : m))); + setEditingMerchId(null); + setNewMerchImage(null); + setMerchImagePreview(""); + } else { + const errorData = await res.json(); + setMerchError(errorData.message || "Failed to update merch item"); + } + } catch (err) { + setMerchError("Failed to update merch item. Please try again."); + } + }; + + const handleDeleteMerch = async (merchId: string) => { + if (!confirm("Are you sure you want to delete this merch item?")) return; + + try { + const res = await DELETE(`/api/merch/${merchId}`); + if (res.ok) { + setMerchItems(merchItems.filter((m) => m._id !== merchId)); + } else { + setMerchError("Failed to delete merch item"); + } + } catch (err) { + setMerchError("Failed to delete merch item. Please try again."); + } + }; + + const startEditingMerch = (merch: MerchItem) => { + setEditingMerchId(merch._id); + setNewMerchImage(null); + setMerchImagePreview(merch.image); + if (merchNameRef.current) merchNameRef.current.value = merch.name; + if (merchPriceRef.current) merchPriceRef.current.value = merch.price.toString(); + if (merchDescriptionRef.current) merchDescriptionRef.current.value = merch.description; + }; + + const cancelMerchEdit = () => { + setEditingMerchId(null); + setIsAddingMerch(false); + setNewMerchImage(null); + setMerchImagePreview(""); + setMerchError(""); + }; + + // Star Rating Component + const StarRating = ({ rating = 0 }: { rating?: number }) => { + return ( +
+ {[1, 2, 3, 4, 5].map((star) => ( + + ))} + ({rating}) +
+ ); + }; + + if (loading) { + return ( + <> + + Student Organization Profile - Low-Price Center + +
+

Loading...

+
+ + ); + } + + if (canAccessMyOrg === false) { + return ( + <> + + Access denied - Low-Price Center + +
+

+ You don't have access to My Organization. Only approved organization accounts can create and manage a profile. +

+
+ + ); + } + + const isCreating = !organization; + + // Render create/edit form in modal + if (isCreating || isEditing || showEditModal) { + return ( + <> + + + {isCreating ? "Create" : "Edit"} - Student Organization Profile + + +
+
+

+ {isCreating ? "Create Student Organization Profile" : "Edit Profile"} +

+ +
+ {/* Profile Picture */} +
+ +
+ {profilePicturePreview && ( + Profile preview + )} +
+ + {fileError &&

{fileError}

} +
+
+
+ + {/* Organization Name */} +
+ + +
+ + {/* Bio */} +
+ +