diff --git a/README.md b/README.md index aa4ea1e4..3543c8ae 100644 --- a/README.md +++ b/README.md @@ -1,85 +1,135 @@ -# Smart-Health-Predictive - -## Getting Started -### Prerequisites -- Node.js v22.x or higher -- npm -- FastApi (Python) -- Docker Desktop - -### Running the Web Application -1. Open a terminal and navigate to the project directory. -2. Run the server. -```node server\src\server.js``` -3. Open a second terminal and navigate to the client folder in the project directory. -4. Install packages with npm and run the client. -```npm install``` -```npm start``` - -## Installing Required Modules + + + WellAI logo + + + +# WellAI - Smart Health Predictive + +Smart Health Predictive empowers individuals to take control of their well-being through data-driven insights. Using AI-powered health analytics, WellAI helps you understand potential health risks early and make informed lifestyle choices for a better, healthier future. + + +## Prerequisites +- Node.js v22+ +- [Docker Desktop](https://www.docker.com/products/docker-desktop/) + + +## How to Build + +1. To build the project: +```shell +# Navigate to https://github.com/A-Axisa/Smart-Health-Predictive + +# Ensure Git is installed +# Open a terminal (Command Prompt or PowerShell for Windows, Terminal for macOS or Linux) + +# Clone the repository +git clone https://github.com/A-Axisa/Smart-Health-Predictive.git + +# Navigate to the project directory and run the services +docker compose up -d +``` + + +## Services +- Frontend: http://localhost:3000/ +- Backend: http://localhost:8000/ +- API Docs: http://localhost:8000/docs + + +## Installing Dependencies (Manual) + +> [!NOTE] +> The docker-compose file already runs these commands automatically. + +### Client +1. Navigate to ```root/client``` and run: +```bash +npm install +``` + ### Server -1. Open command prompt in the server directory of the project. -2. Install the modules listed in the requirements.txt. -```pip3 install -r requirements.txt``` - -## FastApi Setup -### Initial Setup -1. Open Command Prompt -2. Run pip install ```"fastapi[standard]"``` - -### Run FastApi -1. Navigate to FastApi directory -```cd server``` -2. (Optional) Configure extra CORS origins via environment variable (comma-separated). -```CORS_ORIGINS="https://example.com,https://another.example.com"``` -3. Run the server -```fastapi dev main.py``` -4. Go to http://127.0.0.1:8000/docs to interact with API - -## Database Setup (Docker Compose + Alembic) -### Initial Setup -1. Have Docker running in the background. -2. Navigate to the root directory. -3. Create the container with: -```docker compose up -d``` -4. To update your database to the latest version, run the following command: -```alembic upgrade head``` - -*ADDITIONAL NOTES*: - Run the following command to downgrade the database: -**Warning: This command will drop *ALL* existing tables and their associated data** -```alembic downgrade base``` +1. Navigate to ```root/server``` and run: +```bash +pip3 install -r requirements.txt +``` -### Adding a Migration -**Auto-generated migrations** -1. Navigate to ```/server/models/dbmodels.py``` and modify/create tables as necessary. -2. Navigate to the server directory. -3. Run the following command to ensure you are on the current version: -```alembic upgrade head``` -4. Run the following command to create the migration: -```alembic revision --autogenerate -m "Description of migration"``` - -**Manual migrations** -1. Navigate to the server directory. -2. Run the following command to add a new version: -```alembic revision -m "Description of migration"``` -3. Navigate to ```/server/alembic/versions/nameOfNewVersion.py``` and locate the created version file. -4. Refer to *https://alembic.sqlalchemy.org/en/latest/ops.html* for detail on creating/dropping tables. - - -## Generating Dummy Data ## -The script will generate random data directly into the database. + +## Generating Dummy Data + +The script will generate random data directly into the database. The amount of data generated can be modified at the head of the script. -1. Open a terminal and navigate to the root directory of the project. -2. Run the following command to generate data: -```python -m server.tools.generate_dummy_data``` -## Running Playwright +1. Navigate to the project directory: +```bash +python -m server.tools.generate_dummy_data +``` + + +## Using Alembic + +To update your database to the latest migration run: +```bash +alembic upgrade head +``` + +To remove all tables and data run: +```bash +alembic downgrade base +``` + + +### Adding a Migration + +1. Navigate to ```root/server/models/dbmodels.py``` and modify/ create tables as necessary. +2. Navigate to ```root/server``` and run: +```bash +alembic upgrade head +alembic revision --autogenerate -m "Your migration description." + +# This will auto-generate a version in /root/server/alembic/versions/ +``` + +Refer to the [Alembic Docs](https://alembic.sqlalchemy.org/en/latest/ops.html) for more detail. + + +## Testing + +To run all integration tests, navigate to ```root/server```: +```bash +# Run the tests +pytest +``` + +### End-to-end Testing 1. If not already installed, run the command: -```npx playwright install``` -2. Open a terminal and navigate to the Client directory. -3. run the command: -```npx playwright test``` -4. Tests will now begin running and the terminal will log the status of completed tests. -Proceeding this it will open a window in your browser with a more detailed overview. This can also +```bash +npx playwright install +``` +2. Navigate to the ```root/client```: +```bash +npx playwright test +``` +Tests will now begin running and the terminal will log the status of completed tests. +Following this it will open a window in your browser with a more detailed overview. This can also be accessed directly on http://localhost:9323/. + +### Load Testing +1. If not already install k6 on your machine +2. Navigate to the project directory: +```bash +# There are currently a number of test types you can run: +# - average +# - breakpoint +# - smoke +# - soak +# - spike +# - stress + +# To ensure that k6 is operational, run: + +k6 run load-tests/tests/smoke.js +``` + + +[Return to top](#wellai---smart-health-predictive) \ No newline at end of file diff --git a/client/package-lock.json b/client/package-lock.json index 04b79646..1b7d690e 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -24,6 +24,7 @@ "bootstrap": "^5.3.8", "dayjs": "^1.11.20", "file-saver": "^2.0.5", + "html2canvas": "^1.4.1", "libphonenumber-js": "^1.12.24", "react": "^19.1.1", "react-dom": "^19.1.1", @@ -86,6 +87,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz", "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -741,6 +743,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.27.1.tgz", "integrity": "sha512-p9OkPbZ5G7UT1MofwYFigGebnrzGJacoBSQM0/6bi/PUMVE+qlWDD/OalvQKbwgQzU6dl0xAv6r4X7Jme0RYxA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -1624,6 +1627,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.27.1.tgz", "integrity": "sha512-2KH4LWGSrJIkVf5tSiBFYuXDAoWRq2MMwgivCf+93dd0GQi8RXLjKA/0EvRnVV5G0hrHczsquXuD01L8s6dmBw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.1", "@babel/helper-module-imports": "^7.27.1", @@ -2452,6 +2456,7 @@ "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -2495,6 +2500,7 @@ "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -3175,6 +3181,7 @@ "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.4.tgz", "integrity": "sha512-gEQL9pbJZZHT7lYJBKQCS723v1MGys2IFc94COXbUIyCTWa+qC77a7hUax4Yjd5ggEm35dk4AyYABpKKWC4MLw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.28.4", "@mui/core-downloads-tracker": "^7.3.4", @@ -3285,6 +3292,7 @@ "resolved": "https://registry.npmjs.org/@mui/system/-/system-7.3.3.tgz", "integrity": "sha512-Lqq3emZr5IzRLKaHPuMaLBDVaGvxoh6z7HMWd1RPKawBM5uMRaQ4ImsmmgXWtwJdfZux5eugfDhXJUo2mliS8Q==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.28.4", "@mui/private-theming": "^7.3.3", @@ -3952,6 +3960,7 @@ "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/popperjs" @@ -4493,6 +4502,7 @@ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -5103,6 +5113,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz", "integrity": "sha512-TiZzBSJja/LbhNPvk6yc0JrX9XqhQ0hdh6M2svYfsHGejaKFIAGd9MQ+ERIMzLGlN/kZoYIgdxFV0PuljTKXag==", "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.4.0", "@typescript-eslint/scope-manager": "5.62.0", @@ -5156,6 +5167,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.62.0.tgz", "integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==", "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "5.62.0", "@typescript-eslint/types": "5.62.0", @@ -5531,6 +5543,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5629,6 +5642,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -6374,6 +6388,15 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -6630,6 +6653,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", @@ -7474,6 +7498,15 @@ "postcss": "^8.4" } }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/css-loader": { "version": "6.11.0", "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-6.11.0.tgz", @@ -8805,6 +8838,7 @@ "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -10791,6 +10825,19 @@ } } }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/htmlparser2": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz", @@ -11846,6 +11893,7 @@ "resolved": "https://registry.npmjs.org/jest/-/jest-27.5.1.tgz", "integrity": "sha512-Yn0mADZB89zTtjkPJEXwrac3LHudkQMR+Paqa8uxJHCBr9agxztUifWCyiYrjhMPBoUVBjyny0I7XH6ozDr7QQ==", "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^27.5.1", "import-local": "^3.0.2", @@ -12731,6 +12779,7 @@ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -14930,6 +14979,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -16064,6 +16114,7 @@ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -16556,6 +16607,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -16687,6 +16739,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -16821,6 +16874,7 @@ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz", "integrity": "sha512-F27qZr8uUqwhWZboondsPx8tnC3Ct3SxZA3V5WyEvujRyyNv0VYPhoBg1gZ8/MV5tubQp76Trw8lTv9hzRBa+A==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -17369,6 +17423,7 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz", "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==", "license": "MIT", + "peer": true, "bin": { "rollup": "dist/bin/rollup" }, @@ -17611,6 +17666,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -19040,23 +19096,6 @@ } } }, - "node_modules/tailwindcss/node_modules/yaml": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", - "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, "node_modules/tapable": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", @@ -19234,6 +19273,15 @@ "node": ">=8" } }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -19479,6 +19527,7 @@ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", "license": "(MIT OR CC0-1.0)", + "peer": true, "engines": { "node": ">=10" }, @@ -19931,6 +19980,15 @@ "node": ">= 0.4.0" } }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/uuid": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", @@ -20105,6 +20163,7 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.102.0.tgz", "integrity": "sha512-hUtqAR3ZLVEYDEABdBioQCIqSoguHbFn1K7WlPPWSuXmx0031BD73PSE35jKyftdSh4YLDoQNgK4pqBt5Q82MA==", "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -20176,6 +20235,7 @@ "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-4.15.2.tgz", "integrity": "sha512-0XavAZbNJ5sDrCbkpWL8mia0o5WPOd2YGtxrEiZkBK9FjLppIUK2TgxK6qGD2P3hUXTJNNPVibrerKcx5WkR1g==", "license": "MIT", + "peer": true, "dependencies": { "@types/bonjour": "^3.5.9", "@types/connect-history-api-fallback": "^1.3.5", @@ -20588,6 +20648,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", diff --git a/client/package.json b/client/package.json index 4f779057..5e48a6eb 100644 --- a/client/package.json +++ b/client/package.json @@ -19,6 +19,7 @@ "bootstrap": "^5.3.8", "dayjs": "^1.11.20", "file-saver": "^2.0.5", + "html2canvas": "^1.4.1", "libphonenumber-js": "^1.12.24", "react": "^19.1.1", "react-dom": "^19.1.1", diff --git a/client/public/favicon.ico b/client/public/favicon.ico index a11777cc..b2fa7027 100644 Binary files a/client/public/favicon.ico and b/client/public/favicon.ico differ diff --git a/client/public/index.html b/client/public/index.html index aa069f27..ba69d485 100644 --- a/client/public/index.html +++ b/client/public/index.html @@ -9,7 +9,7 @@ name="description" content="Web site created using create-react-app" /> - + - React App + Smart Health Predictive diff --git a/client/public/logo.png b/client/public/logo.png new file mode 100644 index 00000000..b2fa7027 Binary files /dev/null and b/client/public/logo.png differ diff --git a/client/src/components/AppThemeProvider.jsx b/client/src/components/AppThemeProvider.jsx index 018ebdb0..f14dacc8 100644 --- a/client/src/components/AppThemeProvider.jsx +++ b/client/src/components/AppThemeProvider.jsx @@ -1,9 +1,9 @@ -import React from "react"; -import { ThemeProvider, createTheme } from "@mui/material"; import "@fontsource/russo-one"; -import { color, fontWeight } from "@mui/system"; +import { ThemeProvider, createTheme } from "@mui/material"; -// Centralized MUI theme for the app +/** + * A centralized MUI theme for the web app. + */ export const appTheme = createTheme({ shape: { borderRadius: 20, @@ -93,19 +93,19 @@ export const appTheme = createTheme({ elevation: 16, }, variants: [ - { - props: { variant: "report-section" }, - style: ({ theme }) => ({ - borderRadius: 0, - marginInline: theme.spacing(2), - padding: theme.spacing(2), - boxShadow: theme.shadows[3], - borderBottom: `1px solid ${theme.palette.divider}`, - }), - }, - ], + { + props: { variant: "report-section" }, + style: ({ theme }) => ({ + borderRadius: 0, + marginInline: theme.spacing(2), + padding: theme.spacing(2), + boxShadow: theme.shadows[3], + borderBottom: `1px solid ${theme.palette.divider}`, + }), + }, + ], }, - + MuiTypography: { styleOverrides: { root: { @@ -128,15 +128,19 @@ export const appTheme = createTheme({ fontFamily: "'Russo One', 'sans-serif'", }, h4: { - fontWeight: 'regular', + fontWeight: "regular", fontFamily: "'Russo One', 'sans-serif'", }, h5: { - fontWeight: 'regular', + fontWeight: "regular", + fontFamily: "'Russo One', 'sans-serif'", + }, + h6: { + fontWeight: "regular", fontFamily: "'Russo One', 'sans-serif'", }, h7: { - fontWeight: 'regular', + fontWeight: "regular", fontFamily: "'Russo One', 'sans-serif'", }, }, @@ -148,7 +152,6 @@ export const appTheme = createTheme({ disableRowSelectionOnClick: true, autoHeight: false, density: "standard", - autoHeight: false, }, styleOverrides: { root: { @@ -171,10 +174,10 @@ export const appTheme = createTheme({ fontWeight: 600, }, "& .MuiDataGrid-sortIcon": { - opacity: 'inherit !important', + opacity: "inherit !important", }, "& .MuiDataGrid-iconButtonContainer": { - visibility: 'visible', + visibility: "visible", }, }, filler: { diff --git a/client/src/components/DisclaimerPolicy.js b/client/src/components/DisclaimerPolicy.js deleted file mode 100644 index ff2bb85e..00000000 --- a/client/src/components/DisclaimerPolicy.js +++ /dev/null @@ -1,63 +0,0 @@ -import { - Typography, - Dialog, - DialogContent, - DialogTitle, - Box, - Button -} from "@mui/material"; - - -const DisclaimerPolicy = ({ open, onClose }) => { - return ( - - Disclaimer Policy - - Important Information: - - This health predictive application (“App”) provides information and recommendations for your health and wellness. Please carefully read and understand this disclaimer before using the App. - - - Accuracy and Limitations: - - The App's predictions are based on algorithms trained on various health data sources and general health knowledge. These predictions are not the result of medical studies and should not be considered definitive diagnoses or medical advice. - The App does not claim to replace or substitute for professional medical advice, diagnosis, or treatment. - Always consult with your doctor or other qualified healthcare professional before making any changes to your diet, exercise routine, or other health-related decisions. - - - Health Recommendations: - - The App offers personalized health recommendations based on your input and the expertise of qualified dietitians. These recommendations are intended to provide general guidance and support for improving your health and well-being. - The recommendations are not individualized medical advice and may not be suitable for everyone. You should always discuss any recommended changes with your doctor or other qualified healthcare professional before implementing them. - - - Data Privacy and Security: - - We take your privacy seriously and are committed to protecting your personal information. Please refer to our Privacy Policy for details on how we collect, use, and protect your data. - - - Additional Disclaimer: - - We make no guarantees or warranties, express or implied, regarding the accuracy, completeness, or reliability of the information provided by the App. - The App is provided “as is” and “as available” without any warranties or guarantees of any kind. - We are not responsible for any damages or injuries that may arise from your use of the App. - By using the App, you agree to the terms and conditions of this Disclaimer. - - - Contact Us: - - If you have any questions or concerns about this Disclaimer, please contact us at contactus@wellai.app. - This disclaimer is for informational purposes only and does not constitute legal advice. Please consult with an attorney for legal advice. - - - - - - - - ); -}; - -export default DisclaimerPolicy; \ No newline at end of file diff --git a/client/src/components/HealthReportPDFFlat.js b/client/src/components/HealthReportPDFFlat.js deleted file mode 100644 index 48e0b6bf..00000000 --- a/client/src/components/HealthReportPDFFlat.js +++ /dev/null @@ -1,344 +0,0 @@ -import { Page, Text, View, Document, StyleSheet, Image } from '@react-pdf/renderer'; -import WellAiLogo from '../assets/WellAiLogoTR.png'; - -// Styles reused for flat report PDF -const styles = StyleSheet.create({ - page: { - flexDirection: 'column', - backgroundColor: '#FFFFFF', - paddingTop: 100, // Space for fixed header - paddingBottom: 70, // Space for fixed footer - paddingLeft: 40, - paddingRight: 40, - fontFamily: 'Helvetica' - }, - watermarkContainer: { - position: 'absolute', - top: 250, - left: 80, - right: 80, - opacity: 0.08, - justifyContent: 'center', - alignItems: 'center', - zIndex: -1 - }, - watermarkImage: { - width: 400, - height: 400, - objectFit: 'contain' - }, - headerContainer: { - position: 'absolute', - top: 30, - left: 40, - right: 40, - }, - headerTop: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'flex-end', - marginBottom: 5, - }, - headerLeftText: { - fontSize: 10, - color: '#888888', - marginBottom: 5 - }, - logo: { - width: 120, - height: 35, - objectFit: 'contain' - }, - headerLineContainer: { - position: 'relative', - height: 8, - marginBottom: 10, - }, - headerLine: { - position: 'absolute', - top: 4, - left: 0, - right: 0, - height: 1, - backgroundColor: '#888888' - }, - headerBottom: { - flexDirection: 'row', - justifyContent: 'space-between', - marginTop: 5, - }, - metaText: { - fontSize: 10, - color: '#000', - }, - content: { - flex: 1, - }, - reportTitle: { - fontFamily: 'Helvetica-Bold', - fontSize: 20, - textAlign: 'center', - textDecoration: 'underline', - marginBottom: 15, - }, - sessionTitle: { - fontFamily: 'Helvetica-Oblique', - fontSize: 12, - textDecoration: 'underline', - marginBottom: 5, - }, - table: { - borderWidth: 1, - borderColor: '#000', - marginBottom: 20, - }, - tableHeader: { - flexDirection: 'row', - backgroundColor: '#EBEBEB', - borderBottomWidth: 1, - borderBottomColor: '#000', - }, - tableHeaderCell: { - padding: 5, - justifyContent: 'center', - alignItems: 'center', - }, - tableHeaderCellText: { - fontFamily: 'Helvetica-Bold', - fontSize: 10, - textAlign: 'center', - }, - tableRow: { - flexDirection: 'row', - borderBottomWidth: 1, - borderBottomColor: '#000', - }, - tableCell: { - padding: 5, - fontSize: 10, - justifyContent: 'center', - alignItems: 'center', - textAlign: 'center', - }, - tableCellTitle: { - fontFamily: 'Helvetica-Bold', - fontSize: 10, - textAlign: 'center', - }, - recommendationSection: { - marginBottom: 12, - }, - recommendationLabel: { - fontSize: 10, - marginBottom: 2, - fontFamily: 'Helvetica', - }, - bulletRow: { - flexDirection: 'row', - marginBottom: 3, - paddingLeft: 20, - paddingRight: 10, - }, - bulletSign: { - width: 12, - fontSize: 10, - fontFamily: 'Helvetica', - }, - bulletContent: { - flex: 1, - fontSize: 10, - lineHeight: 1.4, - }, - disclaimer: { - fontSize: 9, - lineHeight: 1.3, - textAlign: 'justify', - }, - privacyText: { - fontSize: 9, - }, - link: { - color: '#0066cc', - textDecoration: 'underline', - }, - footerContainer: { - position: 'absolute', - bottom: 30, - left: 40, - right: 40, - }, - footerLine: { - height: 1, - backgroundColor: '#888888', - marginBottom: 5, - }, - footerContent: { - flexDirection: 'row', - justifyContent: 'space-between', - }, - footerText: { - fontSize: 9, - color: '#888', - } -}); - -const getRisk = (val) => { - const percentage = Number(val || 0); - if (percentage >= 50) return { label: 'HIGH', color: '#FF0000' }; - if (percentage >= 30) return { label: 'MEDIUM', color: '#FFB800' }; - return { label: 'LOW', color: '#00B050' }; -}; - -const pct = (v) => `${Number(v || 0).toFixed(1)}%`; - -const TableRow = ({ index, disease, chance, risk, noBorderBottom }) => ( - - - {index} - - - {disease} - - - {pct(chance)} - - - {risk.label} - - -); - -const renderParagraph = (text) => { - if (!text) return ( - - - - N/A - - ); - - const lines = text.replace(/\r/g, '').split('\n'); - return lines.map((line, idx) => { - let t = line.trim(); - if (!t) return null; - if (t.startsWith('-') || t.startsWith('•') || t.startsWith('*')) { - t = t.substring(1).trim(); - } - return ( - - - - {t} - - ); - }); -}; - -// PDF for flat report data returned from /getReportData/{id} -// Props: { data, metaId, metaDate } -const HealthReportPDFFlat = ({ data, metaId, metaDate }) => { - const dateObj = metaDate ? new Date(metaDate) : new Date(); - const dateStr = dateObj.toLocaleDateString('en-GB', { - day: 'numeric', month: 'long', year: 'numeric' - }); - - const patientName = (data.patientName || '').trim() || 'Patient'; - - return ( - - - {/* Background Watermark */} - - - - - {/* Fixed Header */} - - - Predictive Health Report for Early Detection - - - - - - - Name: {patientName} - Report date: {dateStr} - - - - {/* Fixed Footer */} - - - - All Copyright Reserved © {dateObj.getFullYear()} - `Page ${pageNumber} of ${totalPages}`} /> - - - - {/* Body Content */} - - {/* Title */} - Health Prediction Report - - {/* Session 1 */} - Session 1 - Analysis Result - - {/* Table Header */} - - No. - Disease Description - AI-Powered{'\n'}Prediction Result - Risk{'\n'}Category - - - {/* Rows */} - - - - - - {/* Session 2 */} - Session 2 – Personal Health Recommendations - - - Lifestyle: - {renderParagraph(data.lifestyleRecommendation || data.exerciseRecommendation)} - - - - Food Intake: - {renderParagraph(data.dietRecommendation)} - - - - Food to Avoid: - {renderParagraph(data.dietToAvoidRecommendation)} - - - - Exercise Recommendations: - {renderParagraph(data.exerciseRecommendation)} - - - {/* Break hint: we want disclaimer to either stay at the end or push to next page. */} - - Disclaimer: The information provided by this health prediction report is intended for informational - purposes only and should not be construed as medical advice. Always consult with a qualified - healthcare professional regarding any questions or concerns you may have about your health. Kindly - note that the predictions may not be completely accurate and may not take into account all relevant - factors, and results may vary depending on your individual health history and circumstances. By using - this prediction report, you agree that you understand and accept the limitations of the information - provided. - - - - - For our privacy notice, please refer to https://wellai.app/privacy-notice/. - - - - - - ); -}; - -export default HealthReportPDFFlat; diff --git a/client/src/components/Navbar.js b/client/src/components/Navbar.js index 0e4d35a9..283ac71e 100644 --- a/client/src/components/Navbar.js +++ b/client/src/components/Navbar.js @@ -1,50 +1,55 @@ -import AppBar from "@mui/material/AppBar"; -import Box from "@mui/material/Box"; -import Toolbar from "@mui/material/Toolbar"; -import Typography from "@mui/material/Typography"; -import Button from "@mui/material/Button"; -import Menu from "@mui/material/Menu"; -import { useNavigate, useLocation } from "react-router-dom"; -import MenuItem from "@mui/material/MenuItem"; -import { useContext, useState } from "react"; -import logo from "../assets/WellAiLogoTR.png"; import { + Backdrop, Drawer, + IconButton, List, ListItemButton, - IconButton, Tooltip, - ClickAwayListener, - Backdrop, } from "@mui/material"; +import AppBar from "@mui/material/AppBar"; +import Box from "@mui/material/Box"; +import Toolbar from "@mui/material/Toolbar"; +import Typography from "@mui/material/Typography"; +import { useContext, useState } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; +import logo from "../assets/WellAiLogoTR.png"; import { UserContext } from "../utils/UserContext"; -import PrivacyNotice from "./PrivacyNotice"; -import DisclaimerPolicy from "./DisclaimerPolicy"; +import DisclaimerPolicy from "./dialog/DisclaimerPolicy"; +import PrivacyNotice from "./dialog/PrivacyNotice"; // Icons -import AccountCircleIcon from "@mui/icons-material/AccountCircle"; +import ChevronLeftIcon from "@mui/icons-material/ChevronLeft"; import DashboardIcon from "@mui/icons-material/Dashboard"; -import RestorePageIcon from "@mui/icons-material/RestorePage"; -import TimelineIcon from "@mui/icons-material/Timeline"; -import HistoryIcon from "@mui/icons-material/History"; -import SettingsIcon from "@mui/icons-material/Settings"; -import LogoutIcon from "@mui/icons-material/Logout"; +import FileCopyIcon from "@mui/icons-material/FileCopy"; import GroupIcon from "@mui/icons-material/Group"; +import HistoryIcon from "@mui/icons-material/History"; import HowToRegIcon from "@mui/icons-material/HowToReg"; -import FileCopyIcon from "@mui/icons-material/FileCopy"; +import LogoutIcon from "@mui/icons-material/Logout"; import ManageAccountsIcon from "@mui/icons-material/ManageAccounts"; import MenuIcon from "@mui/icons-material/Menu"; -import ChevronLeftIcon from "@mui/icons-material/ChevronLeft"; +import RestorePageIcon from "@mui/icons-material/RestorePage"; +import SettingsIcon from "@mui/icons-material/Settings"; +import TimelineIcon from "@mui/icons-material/Timeline"; const API_BASE = process.env.REACT_APP_API_URL || "http://localhost:8000"; +/** + * A navigation bar located on the side of the page that allows quick navigation + * of the service. Different tabs will be displayed based on the current + * user's role and provide access to the appropriate pages. + * The service's disclaimer policy and privacy notice are also located at the bottom. + * + * @param {Object} props + * @param {string} [props.role] - Renders different tabs based on a user's role. + * @returns {<>} + */ const NavBar = ({ role }) => { // Maps each route to the corrosponding page title. const routePageMap = { // User Pages "/user-landing": "Dashboard", "/generate-report": "Generate Report", - "/ai-health-prediction": "Report History", + "/report-history": "Report History", "/health-analytics": "Health Analytics", "/user-settings": "Settings", @@ -105,9 +110,6 @@ const NavBar = ({ role }) => { { icon: , title: "Logout" }, ]; - // Check if settings menu is open - const [openMenu, setOpenMenu] = useState(null); - // User Logout async function logout() { await fetch(`${API_BASE}/logout`, { @@ -159,11 +161,9 @@ const NavBar = ({ role }) => { navigate("/health-analytics"); } if (page === "Settings") { - handleCloseSettings(); navigate("/user-settings"); } if (page === "Logout") { - handleCloseSettings(); logout(); } if (page === "Users") { @@ -186,15 +186,6 @@ const NavBar = ({ role }) => { } } - function handleOpenSettings(e) { - setOpenMenu(e.currentTarget); - console.log(openMenu); - } - - function handleCloseSettings() { - setOpenMenu(null); - } - // Navigation Bar for standard user type if (role === "standard_user") return ( diff --git a/client/src/components/PrivacyNotice.js b/client/src/components/PrivacyNotice.js deleted file mode 100644 index 450d0daa..00000000 --- a/client/src/components/PrivacyNotice.js +++ /dev/null @@ -1,125 +0,0 @@ -import { - Typography, - Dialog, - DialogContent, - DialogTitle, - Box, - Button -} from "@mui/material"; - - -const PrivacyNotice = ({ open, onClose }) => { - return ( - - Privacy Notice - - - This Privacy Notice is issued by WellAI Sdn Bhd (“WellAI”) to regulate the collection and processing of your Personal Data (as herein defined) in strict accordance with the requirements of the Personal Data Protection Act, Malaysia, 2010 (PDPA). - By interacting with us, submitting information to us, or signing up for any products and services offered by us, you agree and consent to WellAI, as well as its representatives and/or agents collecting, using, disclosing and sharing amongst themselves your Personal Data, and disclosing such Personal Data to our authorised service providers and relevant third parties in the manners set forth in this Privacy Notice. - This Privacy Notice supplements but does not supersede nor replace any other consents you may have previously provided to WellAI in respect of your Personal Data, and your consents herein are additional to any rights which any member of WellAI may have at law to collect, use, or disclose your Personal Data. - Please note that WellAI may amend this Privacy Notice at any time without prior notice and will notify you of any such amendment via our website or by email. - - - 1. Definitions - - 1.1. WellAI: Refers to WellAI Sdn Bhd, its affiliates, subsidiaries, associated entities, including but not limited to any of their branches and offices. - 1.2. Personal Data: Includes information such as name, age, gender, identity card number, passport number, date of birth, education, race, ethnic origin, nationality, contact details, family information, occupation details, medical and personal health information, demographic information, device information, device location, payment card number and expiry date, billing address, loyalty program membership details, photographs, video and audio, CCTV recordings and other images, preferences and interests, financial details, and any other information relevant to services provided. - - - 2. Information Collection - - 2.1. We may collect Personal Data directly from you or from third parties, including but not limited to online forms, mobile applications, website, email, agreements, name cards, browser, files in media or storage, social medias, or any identity materials that you have distributed voluntarily. - 2.2. Information collected may include personal details, contact details, medical history, device information, and more. Providing obligatory information is necessary for the provision of services. - - - 3. Use of Information - - 3.1. The collected data may be used for various purposes including but not limited to processing medical services, administering services and events, processing payments, internal analysis, customer loyalty programmes, internal investigations, audit or security purposes, compliance with legal obligations, marketing and communication of relevant information. - - - 4. Disclosure of Information - - 4.1. Personal Data will be kept confidential but may be disclosed to relevant third parties, including other companies within the WellAI and relevant third parties (in or outside of Malaysia) as required under law, pursuant to the relevant contractual relationship or for purposes mentioned in paragraph 3 above. - 4.2. WellAI may also disclose or transfer Personal Data in the event of any restructuring, sale, or acquisition of the respective companies. - 4.3 Where WellAI deals with third parties, specific security and confidentiality safeguards will be put in place to ensure the personal data protection rights remain unaffected. - - - 5. Your Rights - - 5.1. You have the right to request access, correction, updating, or deletion of your Personal Data. Requests can be made through the designated contact points provided including but not limited to online registered account or request to pdpa@wellai.app. - 5.2. WellAI reserve the right to charge a fee for processing data access requests in accordance with PDPA. - - - 6. Withdrawing Consent - - 6.1. You are entitled to limit our processing of your personal data by expressly withdrawing in full, your consent given previously, in each case, including for direct marketing purposes subject to any applicable legal restrictions, contractual conditions, and within a reasonable amount of time period. You may opt out of receiving any communications from us at any time by: - 6.1.1 following the opt-out instructions or by clicking on the “unsubscribe link” contained in each marketing communication; - 6.1.2 editing the relevant account settings to unsubscribe; or - 6.1.3 sending a request to pdpa@wellai.app. - - - 7. Complaints - - 7.1. For any concerns or complaints regarding the use of Personal Data, you may contact the designated email address via pdpa@wellai.app. - - - 8. Children - - 8.1. Minors under the age of 18 may not use the respective websites or apps. WellAI does not knowingly collect personal information from anyone under the age of 18. No part of the website or application is designed to attract anyone under the age of 18. WellAI does not sell products and/or services for purchase by children. In certain instances, WellAI sells products and/or services for children but for purchase by adults. - 8.2 Links to Third Party Website - Parts of our website may contain links to third party websites not owned by WellAI (“Third Party websites”). When you access a Third-Party website, please understand that we do not control the content of that Third Party website and are not responsible for the privacy practices or the content of that Third Party website. This Policy applies only to our site and you should be aware that other sites linked by this web site may have different privacy and personal data protection policies and we highly recommend that you read and understand the privacy statement of each site. We accept no responsibility or liability in respect of any such third-party materials or for the operation or content of other websites (whether or not linked to our website) which are not under WellAI's control. - - - 9. External Links - - 9.1. The websites or apps may contain links to third-party websites. WellAI is not responsible for the privacy practices or content of these external sites. - - - 10. Security Precautions - - 10.1. WellAI has stringent security measures in place to protect against unauthorized access, loss, misuse and alteration to Personal Data. - 10.2 A "Force Majeure Event" shall mean any event that is beyond the reasonable control and shall include, without limitation, sabotage, fire, flood, explosion, acts of God, civil commotion, strikes or industrial action of any kind, riots, insurrection, war, acts of government, computer hacking, unauthorised access to computer data and storage device, computer crashes, breach of security and encryption, etc. - - - 11. Changes to this Privacy Policy - - 11.1. WellAI reserves the right to update, change, or modify this Privacy Policy. Such changes will be effective from the date of the update. If You continue to use Your account and/or access the Website/App even after any such changes have been made, it would be deemed to be an implied consent to the changed policy. - - - 12. Cookies and Other Tracking Technologies - - 12.1. The websites or apps may use cookies and tracking technologies to collect information about user activity for analysis and customization of user experience. A "cookie" is a small text file that may be used, for example, to collect information about Site activity. Some cookies and other technologies may serve to recall Personal Information previously indicated by a Web user. Most browsers allow You to control cookies, including whether or not to accept them and how to remove them. - 12.2 Tracking technologies may record information such as Internet domain and host names; Internet protocol (IP) addresses; browser software and operating system types; stream patterns; and dates and times that our website or application is accessed. Our use of cookies and other tracking technologies allows WellAIs to improve our website, application and user experience. We may also analyze information that does not contain Personal Information for trends and statistics. - - - 13. Data Transfer and Sharing - - 13.1. Personal Data may be transferred to third parties, including within the respective group of companies or any third party service or product providers within or outside the country, for purposes of data storage, processing, and service provision. - - - 14. Indemnity - - 14.1. Users agree to indemnify WellAI against any disputes arising from the disclosure of Personal Information to third parties either through WellAI website or application or otherwise. We assume no liability for any actions of third parties with regard to the Personal Data, which you may have disclosed to such third parties. - - - 15. Severability - - 15.1. Each paragraph of this Privacy Policy is separate and independent. Nullification of one paragraph does not affect the other paragraphs herein except where otherwise expressly indicated or indicated by the context of the agreement. - - - 16. Contact Information - - 16.1. For any feedback or comments about this Privacy Policy, you may contact the designated email address via pdpa@wellai.app. - - - - - - - - ); -}; - -export default PrivacyNotice; \ No newline at end of file diff --git a/client/src/components/WelcomePanel.js b/client/src/components/WelcomePanel.js index 4137a6ee..f75c52a2 100644 --- a/client/src/components/WelcomePanel.js +++ b/client/src/components/WelcomePanel.js @@ -2,7 +2,10 @@ import { Box, Container, Stack, Typography } from "@mui/material"; import Logo from "../assets/WellAiLogoTR.png"; /** - * An panel that introduce the web service. + * A panel that displays the name of the web application and the + * company's logo as an introduction to the web application. + * + * @returns {@mui.material.Container} */ const WelcomePanel = () => { return ( diff --git a/client/src/components/administrator/AccountApprovalTable.js b/client/src/components/administrator/AccountApprovalTable.js index 58a490b8..17c83ddf 100644 --- a/client/src/components/administrator/AccountApprovalTable.js +++ b/client/src/components/administrator/AccountApprovalTable.js @@ -1,11 +1,16 @@ -import { useState, useEffect } from "react"; -import { Paper, Button, Box } from "@mui/material"; +import { Box, Button, Paper } from "@mui/material"; import { DataGrid } from "@mui/x-data-grid"; -import ConfirmationDialog from "../confirmationDialog"; +import { useEffect, useState } from "react"; +import ConfirmationDialog from "../dialog/confirmationDialog"; const API_BASE = process.env.REACT_APP_API_URL || "http://localhost:8000"; -const AccountApprovalTable = ({}) => { +/** + * A table that lists the merchant accounts awaiting approval by an administrator. + * + * @returns {@mui.material.Box} + */ +const AccountApprovalTable = () => { const [userData, setUserData] = useState([]); // Stores user data const [selectedUser, setselectedUser] = useState(); // Stores the selected user const [dialogOpen, setDialogOpen] = useState(false); // Stores dialog state @@ -20,7 +25,7 @@ const AccountApprovalTable = ({}) => { }) .then((data) => setUserData(data)) .catch((err) => { - console.log(err); + console.error("Failed to fetch merchant data."); }); }; @@ -44,14 +49,32 @@ const AccountApprovalTable = ({}) => { fetchMerchants(); }) .catch((err) => { - console.log(err); + console.error("Failed to validate merchant account."); }); }; const columns = [ - { field: "email", headerName: "Email", flex: 2, width: 250, sortable: true }, - { field: "fullName", headerName: "Full Name", flex: 1.5, width: 250, sortable: true }, - { field: "createdAt", headerName: "Created At", flex: 1.2, width: 200, sortable: true }, + { + field: "email", + headerName: "Email", + flex: 2, + width: 250, + sortable: true, + }, + { + field: "fullName", + headerName: "Full Name", + flex: 1.5, + width: 250, + sortable: true, + }, + { + field: "createdAt", + headerName: "Created At", + flex: 1.2, + width: 200, + sortable: true, + }, { field: "confirm", headerName: "Confirm", diff --git a/client/src/components/administrator/AuditLogSearchBar.js b/client/src/components/administrator/AuditLogSearchBar.js index 82461176..f26e9740 100644 --- a/client/src/components/administrator/AuditLogSearchBar.js +++ b/client/src/components/administrator/AuditLogSearchBar.js @@ -1,7 +1,19 @@ import SearchIcon from "@mui/icons-material/Search"; -import { TextField, InputAdornment, Box } from "@mui/material"; +import { Box, InputAdornment, TextField } from "@mui/material"; import { memo, useEffect, useState } from "react"; +/** + * A search bar that is used as an element in the AuditLogTable to filter + * the data inside of it. + * + * @param {Object} props + * @param {function} [props.onSearchChange] - Callback function for changes + * in search criteria + * @param {int} [props.delay] - Millisecond delay before calling onSearchChange + * callback + * @param {string} [props.placeholder] - Placeholder text in search bar + * @returns {@mui.material.Box} + */ const AuditLogSearchBar = ({ onSearchChange, delay = 500, placeholder }) => { const [value, setValue] = useState(""); diff --git a/client/src/components/administrator/AuditLogTable.js b/client/src/components/administrator/AuditLogTable.js index 131435ce..5a9d4d7e 100644 --- a/client/src/components/administrator/AuditLogTable.js +++ b/client/src/components/administrator/AuditLogTable.js @@ -1,14 +1,14 @@ -import { useCallback, useEffect, useState } from "react"; -import { DataGrid } from "@mui/x-data-grid"; import { - Paper, Box, - Select, - MenuItem, - InputLabel, - FormControl, Divider, + FormControl, + InputLabel, + MenuItem, + Paper, + Select, } from "@mui/material"; +import { DataGrid } from "@mui/x-data-grid"; +import { useCallback, useEffect, useState } from "react"; import AuditLogSearchBar from "./AuditLogSearchBar"; const API_BASE = process.env.REACT_APP_API_URL || "http://localhost:8000"; @@ -31,6 +31,12 @@ const EVENT_TYPES = [ "PATIENT_PROFILE_UPDATED", ]; +/** + * A table that lists the system's audit logs with the tools to filter + * them efficiently. + * + * @returns {@mui.material.Box} + */ const AuditLogTable = () => { const [logData, setLogData] = useState([]); const [totalLogs, setTotalLogs] = useState(0); @@ -47,8 +53,9 @@ const AuditLogTable = () => { const [userEmail, setUserEmail] = useState(""); const [eventType, setEventType] = useState(""); - const fetchLogs = () => { + const fetchLogs = useCallback(() => { setLoading(true); + const params = new URLSearchParams({ skip: paginationModel.page * paginationModel.pageSize, limit: paginationModel.pageSize, @@ -56,12 +63,15 @@ const AuditLogTable = () => { if (userEmail) params.append("user_email", userEmail); if (eventType) params.append("event_type", eventType); + if (sortModel.length > 0) { params.append("sort_by", sortModel[0].field); params.append("sort_order", sortModel[0].sort || "desc"); } - fetch(`${API_BASE}/logs?${params.toString()}`, { credentials: "include" }) + fetch(`${API_BASE}/logs?${params.toString()}`, { + credentials: "include", + }) .then((response) => { if (!response.ok) { throw new Error(response.status); @@ -74,14 +84,20 @@ const AuditLogTable = () => { setLoading(false); }) .catch((err) => { - console.log(err); + console.error("Failed to fetch log data."); setLoading(false); }); - }; + }, [ + paginationModel.page, + paginationModel.pageSize, + userEmail, + eventType, + sortModel, + ]); useEffect(() => { fetchLogs(); - }, [paginationModel.page, paginationModel.pageSize, userEmail, eventType, sortModel]); + }, [fetchLogs]); const handleEmailSearchChange = useCallback((value) => { setPaginationModel((prev) => ({ ...prev, page: 0 })); @@ -94,14 +110,62 @@ const AuditLogTable = () => { }, []); const columns = [ - { field: "logID", headerName: "Log ID", flex: 0.7, width: 70, sortable: true }, - { field: "eventType", headerName: "Event Type", flex: 1, minWidth: 140, sortable: true }, - { field: "success", headerName: "Success", width: 80, type: "boolean", sortable: true }, - { field: "userEmail", headerName: "User Email", flex: 1.5, minWidth: 180, sortable: true }, - { field: "ipAddress", headerName: "IP Address", flex: 0.8, minWidth: 100, sortable: true }, - { field: "device", headerName: "Device", flex: 1, minWidth: 120, sortable: false }, - { field: "createdAt", headerName: "Created At", flex: 1, minWidth: 160, sortable: true }, - { field: "description", headerName: "Description", flex: 2, minWidth: 200, sortable: false }, + { + field: "logID", + headerName: "Log ID", + flex: 0.7, + width: 70, + sortable: true, + }, + { + field: "eventType", + headerName: "Event Type", + flex: 1, + minWidth: 140, + sortable: true, + }, + { + field: "success", + headerName: "Success", + width: 80, + type: "boolean", + sortable: true, + }, + { + field: "userEmail", + headerName: "User Email", + flex: 1.5, + minWidth: 180, + sortable: true, + }, + { + field: "ipAddress", + headerName: "IP Address", + flex: 0.8, + minWidth: 100, + sortable: true, + }, + { + field: "device", + headerName: "Device", + flex: 1, + minWidth: 120, + sortable: false, + }, + { + field: "createdAt", + headerName: "Created At", + flex: 1, + minWidth: 160, + sortable: true, + }, + { + field: "description", + headerName: "Description", + flex: 2, + minWidth: 200, + sortable: false, + }, ]; return ( @@ -128,9 +192,10 @@ const AuditLogTable = () => { /> { Filter by Event Type @@ -174,11 +239,10 @@ const AuditLogTable = () => { - - + { }, }} /> - + ); diff --git a/client/src/components/administrator/UserManagementTable.js b/client/src/components/administrator/UserManagementTable.js index ca06daca..35dab651 100644 --- a/client/src/components/administrator/UserManagementTable.js +++ b/client/src/components/administrator/UserManagementTable.js @@ -1,11 +1,18 @@ -import { Paper, Box, Snackbar, Alert, Stack, Typography, Divider } from "@mui/material"; +import { Alert, Box, Divider, Paper, Snackbar } from "@mui/material"; import { DataGrid } from "@mui/x-data-grid"; -import { useState, useEffect, useCallback } from "react"; -import ConfirmationDialog from "../confirmationDialog"; +import * as React from "react"; +import { useCallback, useEffect, useState } from "react"; +import ConfirmationDialog from "../dialog/confirmationDialog"; import UserSearchBar from "./UserSearchBar"; import UserToolBar from "./UserToolBar"; -import * as React from "react"; +/** + * A table listing all the user accounts in the system with tools to search + * and filter through them. It details the account's email, full name, + * creation date, validation status, and role. + * + * @returns {@mui.material.Box} + */ const UserManagementTable = () => { const [userData, setUserData] = useState([]); // Stores user data const [totalUsers, setTotalUsers] = useState(0); @@ -37,19 +44,22 @@ const UserManagementTable = () => { const API_BASE = process.env.REACT_APP_API_URL || "http://localhost:8000"; - const fetchUsers = () => { + const fetchUsers = useCallback(() => { setLoading(true); + const params = new URLSearchParams({ skip: paginationModel.page * paginationModel.pageSize, limit: paginationModel.pageSize, }); + if (debouncedSearchQuery) { params.append("search", debouncedSearchQuery); } + if (selectedClinic) { params.append("clinic_id", selectedClinic); } - + // Append sort params when sort is active if (sortModel.length > 0) { params.append("sort_by", sortModel[0].field); @@ -69,13 +79,9 @@ const UserManagementTable = () => { setLoading(false); }) .catch((err) => { - console.log(err); + console.error("Failed to fetch user data."); setLoading(false); }); - }; - - useEffect(() => { - fetchUsers(); }, [ API_BASE, paginationModel.page, @@ -85,6 +91,10 @@ const UserManagementTable = () => { sortModel, ]); + useEffect(() => { + fetchUsers(); + }, [fetchUsers]); + useEffect(() => { setPaginationModel((prev) => ({ ...prev, page: 0 })); }, [API_BASE]); @@ -99,11 +109,11 @@ const UserManagementTable = () => { }) .then((data) => setRoleData(data)) .catch((err) => { - console.log(err); + console.error("Failed to fetch role data."); }); }, [API_BASE]); - useEffect(() => { + useEffect(() => { fetch(`${API_BASE}/clinics`) .then((response) => { if (!response.ok) { @@ -113,11 +123,10 @@ const UserManagementTable = () => { }) .then((data) => setClinicData(data)) .catch(() => { - console.log("Failed to fetch Clinics"); + console.error("Failed to fetch clinic data."); }); }, [API_BASE]); - const confirmRoleChange = async (e) => { e.preventDefault(); const emails = getSelectedEmails(); @@ -130,6 +139,7 @@ const UserManagementTable = () => { { method: "PATCH", headers: { "Content-Type": "application/json" }, + credentials: "include", }, ); if (!response.ok) throw new Error(response.status); @@ -155,7 +165,7 @@ const UserManagementTable = () => { setNewRole(null); setRowSelectionModel({ type: "include", ids: new Set() }); } catch (err) { - console.log(err); + console.error("Failed to process request."); } }; @@ -189,7 +199,7 @@ const UserManagementTable = () => { setUserToDelete(null); setRowSelectionModel({ type: "include", ids: new Set() }); // Reset the checkbox after deletion. } catch (error) { - console.error("Delete user error:", error); + console.error("Failed to delete user."); setSnackbar({ open: true, message: error.message, severity: "error" }); } }; @@ -228,11 +238,42 @@ const UserManagementTable = () => { }, []); const columns = [ - { field: "email", headerName: "Email", flex: 2, width: 250, sortable: true }, - { field: "fullName", headerName: "Full Name", flex: 1.5, width: 250, sortable: false }, - { field: "createdAt", headerName: "Created At", flex: 1.2, width: 200, sortable: true }, - { field: "validated", headerName: "Validation Status", flex: 1, width: 150, sortable: true }, - { field: "role", headerName: "Role", flex: 1.3, width: 220, sortable: false, valueGetter: (params) => params?.name }, + { + field: "email", + headerName: "Email", + flex: 2, + width: 250, + sortable: true, + }, + { + field: "fullName", + headerName: "Full Name", + flex: 1.5, + width: 250, + sortable: false, + }, + { + field: "createdAt", + headerName: "Created At", + flex: 1.2, + width: 200, + sortable: true, + }, + { + field: "validated", + headerName: "Validation Status", + flex: 1, + width: 150, + sortable: true, + }, + { + field: "role", + headerName: "Role", + flex: 1.3, + width: 220, + sortable: false, + valueGetter: (params) => params?.name, + }, ]; return ( @@ -246,11 +287,12 @@ const UserManagementTable = () => { }} > - { delay={400} /> - + - + diff --git a/client/src/components/administrator/UserSearchBar.js b/client/src/components/administrator/UserSearchBar.js index e4f1b7e7..595f84e3 100644 --- a/client/src/components/administrator/UserSearchBar.js +++ b/client/src/components/administrator/UserSearchBar.js @@ -1,8 +1,18 @@ import SearchIcon from "@mui/icons-material/Search"; -import { TextField, Box } from "@mui/material"; +import { Box, TextField } from "@mui/material"; import InputAdornment from "@mui/material/InputAdornment"; -import { useEffect, useState, memo } from "react"; +import { memo, useEffect, useState } from "react"; +/** + * A search bar that is used as an element in the UserManagementTable to + * filter the data inside of it. + * + * @param {Object} props + * @param {function} [props.onSearchChange] - Callback function for changes in search criteria. + * @param {int} [props.delay] - Millisecond delay before calling onSearchChange callback. + * @param {string} [props.placeholder] - Placeholder text in search bar. + * @returns {@mui.material.Box} + */ const UserSearchBar = ({ placeholder, onSearchChange, delay = 400 }) => { const [inputValue, setInputValue] = useState(""); diff --git a/client/src/components/administrator/UserToolBar.js b/client/src/components/administrator/UserToolBar.js index 7092f49c..495d0a69 100644 --- a/client/src/components/administrator/UserToolBar.js +++ b/client/src/components/administrator/UserToolBar.js @@ -1,6 +1,24 @@ -import { Box, Select, Button, MenuItem } from "@mui/material"; import DeleteForeverIcon from "@mui/icons-material/DeleteForever"; +import { Box, Button, MenuItem, Select } from "@mui/material"; +/** + * A tool bar to that provides the controls for modifying and filtering user + * data including deletion, role changing, and clinic filtering. This + * component is designed to be used with the UserManagementTable. + * + * @param {Object} props + * @param {Object} [props.rowSelectionModel] + * @param {string} [props.rowSelectionModel.type] - "include" or "exclude" selection. + * @param {Set} [props.rowSelectionModel.ids] - User ID's that are selected + * @param {int} [props.totalRowCount] - Number of rows when selection type is "exclude" + * @param {function} [props.onUsersDelete] - Callback function for deleting user + * @param {function} [props.onUsersRoleChange] - Callback function for changing user role + * @param {function} [props.onClinicChange] - Callback function for changing clinic + * @param {Object} [props.roleData] - A list of all roles in the system and their ID + * @param {Object} [props.clinicData] - A list of all clinics in the system and their ID + * @param {int} [props.selectedClinic] - ID of the selected clinic + * @returns {@mui.material.Box} + */ const UserToolBar = ({ rowSelectionModel, totalRowCount, @@ -39,12 +57,10 @@ const UserToolBar = ({ value={selectedClinic} onChange={(e) => onClinicChange(e.target.value)} sx={{ - width: { xs: "100%", sm:200}, + width: { xs: "100%", sm: 200 }, }} > - - All Clinics - + All Clinics {clinicData.map((clinic) => ( {clinic.name} @@ -58,7 +74,7 @@ const UserToolBar = ({ value="" onChange={(e) => onUsersRoleChange(e.target.value)} sx={{ - width: { xs: "100%", sm: "auto" } + width: { xs: "100%", sm: "auto" }, }} > @@ -77,7 +93,7 @@ const UserToolBar = ({ disabled={emailCount === 0} onClick={onUsersDelete} sx={{ - width: { xs: "100%", sm: "auto" } + width: { xs: "100%", sm: "auto" }, }} > Delete Selected diff --git a/client/src/components/administrator/analytics/ActiveMerchantsAnalytics.js b/client/src/components/administrator/analytics/ActiveMerchantsAnalytics.js index f6b6960d..37ba18b6 100644 --- a/client/src/components/administrator/analytics/ActiveMerchantsAnalytics.js +++ b/client/src/components/administrator/analytics/ActiveMerchantsAnalytics.js @@ -1,19 +1,13 @@ -import { - Card, - CardContent, - Container, - Divider, - Paper, - Stack, - Typography, -} from "@mui/material"; +import { Card, CardContent, Stack, Typography } from "@mui/material"; import { useEffect, useState } from "react"; const API_BASE = process.env.REACT_APP_API_URL || "http://localhost:8000"; /** - * Displays the amount of merchants that have logged into the system over the - * past month and past week. + * A card that displays the amount of merchants that have logged into the + * system over the past month and past week. + * + * @returns {@mui.material.Card} */ const ActiveMerchantsAnalytics = () => { const [data, setData] = useState({}); diff --git a/client/src/components/administrator/analytics/ActiveUsersAnalytics.js b/client/src/components/administrator/analytics/ActiveUsersAnalytics.js index a394f804..079e8ab5 100644 --- a/client/src/components/administrator/analytics/ActiveUsersAnalytics.js +++ b/client/src/components/administrator/analytics/ActiveUsersAnalytics.js @@ -1,19 +1,13 @@ -import { - Card, - CardContent, - Container, - Divider, - Paper, - Stack, - Typography, -} from "@mui/material"; +import { Card, CardContent, Stack, Typography } from "@mui/material"; import { useEffect, useState } from "react"; const API_BASE = process.env.REACT_APP_API_URL || "http://localhost:8000"; /** - * Displays the amount of standard users that have logged into the system over the - * past month and past week. + * A card that displays the amount of standard users that have logged into + * the system over the past month and past week. + * + * @returns {@mui.material.Card} */ const ActiveUsersAnalytics = () => { const [data, setData] = useState({}); diff --git a/client/src/components/administrator/analytics/AverageRiskSeriesAnalytics.js b/client/src/components/administrator/analytics/AverageRiskSeriesAnalytics.js index 225c747a..f5810a31 100644 --- a/client/src/components/administrator/analytics/AverageRiskSeriesAnalytics.js +++ b/client/src/components/administrator/analytics/AverageRiskSeriesAnalytics.js @@ -1,29 +1,32 @@ import { Card, CardContent, - Container, - Paper, - Typography, - Select, - MenuItem, + FormControl, Grid, InputLabel, - FormControl, + MenuItem, + Select, + Typography, + useMediaQuery, + useTheme, } from "@mui/material"; -import { Stack } from "@mui/system"; -import { useEffect, useState } from "react"; -import { LineChart } from "@mui/x-charts/LineChart"; import Divider from "@mui/material/Divider"; +import { LineChart } from "@mui/x-charts/LineChart"; import dayjs from "dayjs"; +import { useEffect, useState } from "react"; const API_BASE = process.env.REACT_APP_API_URL || "http://localhost:8000"; -const DEFAULT_YEAR = 2026; /** - * Displays the average progression for stroke, diabetes, and cardiovascular - * disease of the years. + * A card that displays the average progression for stroke, diabetes, + * and cardiovascular disease of the years. + * + * @returns {@mui.material.Card} */ const AverageRiskSeriesAnalytics = () => { + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down("sm")); + const [availableYears, setAvailableYears] = useState([]); const [year, setYear] = useState(""); const [xAxisData, setXAxisData] = useState([]); @@ -71,10 +74,6 @@ const AverageRiskSeriesAnalytics = () => { }); }, [year]); - function extractXAxisData(rawData) { - return rawData.map((d) => new Date(d.date)); - } - return ( @@ -97,7 +96,7 @@ const AverageRiskSeriesAnalytics = () => { Average Disease Risk (%) - + Year } - renderValue={(selected) => selected.join(", ")} - MenuProps={MenuProps} + labelId="smoker-label" + id="smoker-required" + value={smoker} + label="Smoking Status" + onChange={updateSmoker} > - {lifeStyleChoices.map((name) => { - const selected = lifeStyle.includes(name); - const SelectionIcon = selected - ? CheckBoxIcon - : CheckBoxOutlineBlankIcon; + {smokerOptions.map((option) => ( + + {option} + + ))} + - return ( - - - - - ); - })} + {alertSmokerRequired && ( + + *Please enter your smoking status + + )} + + {/* Alcohol Status Selection */} + + Alcohol Consumption + + + + {alertAlcoholRequired && ( + + *Please enter your alcohol consumption + + )} {/* Marital Status Selection */} @@ -611,8 +640,11 @@ const GenerateReportForm = () => { onChange={updateMaritalStatus} label="Marital Status" > - Single - Married + {maritalStatusOptions.map((option) => ( + + {option} + + ))} {alertMaritalStatusRequired && ( @@ -630,10 +662,11 @@ const GenerateReportForm = () => { label="Working Status" onChange={updateWorkingStatus} > - Unemployed - Private - Student - Public + {workingStatusOptions.map((option) => ( + + {option} + + ))} {alertWorkingStatusRequired && ( @@ -641,9 +674,30 @@ const GenerateReportForm = () => { )} + + Race + + + + {alertRaceRequired && ( + *Please enter your race + )} + - + )} - + ); }; diff --git a/client/src/components/RequestPatientAccessForm.js b/client/src/components/patientAccessRequest/RequestPatientAccessForm.js similarity index 90% rename from client/src/components/RequestPatientAccessForm.js rename to client/src/components/patientAccessRequest/RequestPatientAccessForm.js index 269ff178..e417a84a 100644 --- a/client/src/components/RequestPatientAccessForm.js +++ b/client/src/components/patientAccessRequest/RequestPatientAccessForm.js @@ -1,20 +1,14 @@ +import { Box, Button, Card, Divider, Stack, Typography } from "@mui/material"; import { useState } from "react"; import { Link as RouterLink } from "react-router-dom"; -import { - Box, - Container, - Stack, - Button, - Typography, - Link, - Divider, -} from "@mui/material"; -import EmailInputField from "./authentication/EmailInputField"; +import EmailInputField from "../authentication/EmailInputField"; const API_BASE = process.env.REACT_APP_API_URL || "http://localhost:8000"; /** * Provides a merchant user with a form to send an access request to an existing patient. + * + * @returns {@mui.material.Container} */ const RequestPatientAccessForm = () => { const [email, setEmail] = useState(null); @@ -63,14 +57,11 @@ const RequestPatientAccessForm = () => { } return ( - @@ -84,8 +75,10 @@ const RequestPatientAccessForm = () => { style={{ justifyContent: "center" }} > -

Request Access to Existing Patient Record

- + + Request Access to Existing Patient Record{" "} + + Enter the email of the patient you are requesting access for. {errorMessage && ( @@ -154,7 +147,7 @@ const RequestPatientAccessForm = () => {
)} -
+ ); }; diff --git a/client/src/index.js b/client/src/index.js index b5fb0fd5..c4084df1 100644 --- a/client/src/index.js +++ b/client/src/index.js @@ -1,36 +1,46 @@ import React from "react"; import ReactDOM from "react-dom/client"; import "./index.css"; -import App from "./App"; + import reportWebVitals from "./reportWebVitals"; import { BrowserRouter, Routes, Route } from "react-router-dom"; -import ReportHistory from "./routes/ReportHistory"; -import GenerateReport from "./routes/GenerateReport"; -import HealthAnalytics from "./routes/HealthAnalytics"; -import Login from "./routes/Login"; -import Register from "./routes/Register"; -import UserLanding from "./routes/UserLanding"; -import UserSettings from "./routes/UserSettings"; -import MerchantGenerateReport from "./routes/MerchantGenerateReport"; -import AdministratorDashboard from "./routes/AdministratorDashboard"; -import AdministratorApproval from "./routes/AdministratorApproval"; -import AdministratorLogs from "./routes/AdministratorLogs"; -import AdministratorUsers from "./routes/AdministratorUsers"; -import MerchantLanding from "./routes/MerchantLanding"; -import MerchantReports from "./routes/MerchantReports"; + +// Public routes +import Login from "./routes/public/Login"; +import Register from "./routes/public/Register"; +import ForgotPassword from "./routes/public/ForgotPassword"; +import ResetPassword from "./routes/public/ResetPassword"; +import ErrorPage from "./routes/public/ErrorPage"; + +// Standard user routes +import UserLanding from "./routes/standardUser/UserLanding"; +import ReportHistory from "./routes/standardUser/ReportHistory"; +import GenerateReport from "./routes/standardUser/GenerateReport"; +import HealthAnalytics from "./routes/standardUser/HealthAnalytics"; +import UserSettings from "./routes/standardUser/UserSettings"; +import AcceptAccessRequest from "./routes/standardUser/AcceptAccessRequest"; + +// Merchant routes +import MerchantLanding from "./routes/merchant/MerchantLanding"; +import MerchantReports from "./routes/merchant/MerchantReports"; +import MerchantGenerateReport from "./routes/merchant/MerchantGenerateReport"; +import PatientManagement from "./routes/merchant/PatientManagement"; +import PatientDetails from "./routes/merchant/PatientDetails"; +import CreatePatient from "./routes/merchant/CreatePatient"; +import RequestPatientAccess from "./routes/merchant/RequestPatientAccess"; + +// Admin routes +import AdministratorDashboard from "./routes/admin/AdministratorDashboard"; +import AdministratorApproval from "./routes/admin/AdministratorApproval"; +import AdministratorLogs from "./routes/admin/AdministratorLogs"; +import AdministratorUsers from "./routes/admin/AdministratorUsers"; + +// Utils import AppThemeProvider from "./components/AppThemeProvider"; +import { UserProvider } from "./utils/UserContext"; import ProtectedRoutes from "./utils/ProtectedRoutes"; import PublicOnlyRoutes from "./utils/PublicOnlyRoutes"; import LandingRoute from "./utils/LandingRoute"; -import { UserProvider } from "./utils/UserContext"; -import ErrorPage from "./routes/ErrorPage"; -import PatientManagement from "./routes/PatientManagement"; -import PatientDetails from "./routes/PatientDetails"; -import CreatePatient from "./routes/CreatePatient"; -import ForgotPassword from "./routes/ForgotPassword"; -import ResetPassword from "./routes/ResetPassword"; -import AcceptAccessRequest from "./routes/AcceptAccessRequest"; -import RequestPatientAccess from "./routes/RequestPatientAccess"; const root = ReactDOM.createRoot(document.getElementById("root")); root.render( diff --git a/client/src/routes/AcceptAccessRequest.js b/client/src/routes/AcceptAccessRequest.js deleted file mode 100644 index 867b78d4..00000000 --- a/client/src/routes/AcceptAccessRequest.js +++ /dev/null @@ -1,37 +0,0 @@ -import { - Box, - Container, - Stack, - Typography, - Button, - Link, - Divider, -} from "@mui/material"; -import AcceptRequestForm from "../components/AcceptRequestForm"; - -/** - * Displays the form to accept an access request - */ -const AcceptAccessRequest = () => { - return ( - - - - - - ); -}; - -export default AcceptAccessRequest; diff --git a/client/src/routes/MerchantGenerateReport.js b/client/src/routes/MerchantGenerateReport.js deleted file mode 100644 index d080455e..00000000 --- a/client/src/routes/MerchantGenerateReport.js +++ /dev/null @@ -1,23 +0,0 @@ -import { Container, ButtonGroup, Button, Box } from "@mui/material"; -import MerchantReportForm from "../components/MerchantReportForm"; -import { useState } from "react"; - -const MerchantGenerateReport = ({}) => { - const [page, setPage] = useState("manual"); - - return ( - - - - ); -}; - -export default MerchantGenerateReport; diff --git a/client/src/routes/RequestPatientAccess.js b/client/src/routes/RequestPatientAccess.js deleted file mode 100644 index 38503025..00000000 --- a/client/src/routes/RequestPatientAccess.js +++ /dev/null @@ -1,38 +0,0 @@ -import { - Box, - Container, - Stack, - Typography, - Button, - Link, - Divider, -} from "@mui/material"; -import ForgotPasswordForm from "../components/authentication/ForgotPasswordForm"; -import RequestPatientAccessForm from "../components/RequestPatientAccessForm"; - -/** - * Displays the form to request access to a patients record. - */ -const RequestPatientAccess = () => { - return ( - - - - - - ); -}; - -export default RequestPatientAccess; diff --git a/client/src/routes/AdministratorApproval.js b/client/src/routes/admin/AdministratorApproval.js similarity index 70% rename from client/src/routes/AdministratorApproval.js rename to client/src/routes/admin/AdministratorApproval.js index 77caa26e..1ef24148 100644 --- a/client/src/routes/AdministratorApproval.js +++ b/client/src/routes/admin/AdministratorApproval.js @@ -1,12 +1,12 @@ -import AccountApprovalTable from "../components/administrator/AccountApprovalTable"; -import { - Box, - Typography, - Divider, - Stack, - Container -} from "@mui/material"; +import { Box, Container, Divider, Stack, Typography } from "@mui/material"; +import AccountApprovalTable from "../../components/administrator/AccountApprovalTable"; +/** + * A route that displays the merchant approval table where administrators + * can audit and approve the creation of a merchant's account. + * + * @returns {@mui.material.Container} + */ const AdministratorApproval = () => { return ( { pb: { xs: "2px", sm: "0px", md: "4px" }, pr: { xs: "2px", sm: "0px", md: "4px" }, mr: "0px", - maxWidth: "1600px", + maxWidth: "1600px", }} > diff --git a/client/src/routes/AdministratorDashboard.js b/client/src/routes/admin/AdministratorDashboard.js similarity index 73% rename from client/src/routes/AdministratorDashboard.js rename to client/src/routes/admin/AdministratorDashboard.js index cda7d720..28314012 100644 --- a/client/src/routes/AdministratorDashboard.js +++ b/client/src/routes/admin/AdministratorDashboard.js @@ -1,26 +1,26 @@ import { Box, - Card, - CardContent, Container, Divider, Grid, Stack, Typography, } from "@mui/material"; -import { BarChart } from "@mui/x-charts/BarChart"; -import { useEffect, useState } from "react"; -import ActiveMerchantsAnalytics from "../components/administrator/analytics/ActiveMerchantsAnalytics"; -import ActiveUsersAnalytics from "../components/administrator/analytics/ActiveUsersAnalytics"; -import AverageRiskSeriesAnalytics from "../components/administrator/analytics/AverageRiskSeriesAnalytics"; -import LoginActivityAnalytics from "../components/administrator/analytics/LoginActivityAnalytics"; -import PendingMerchantsAnalytics from "../components/administrator/analytics/PendingMerchantsAnalytics"; -import RecentReportsGeneratedAnalytics from "../components/administrator/analytics/RecentReportsGeneratedAnalytics"; -import UnvalidatedAccountAnalytics from "../components/administrator/analytics/UnvalidatedAccountAnalytics"; -import UserAccountAnalytics from "../components/administrator/analytics/UserAccountAnalytics"; - -const API_BASE = process.env.REACT_APP_API_URL || "http://localhost:8000"; +import ActiveMerchantsAnalytics from "../../components/administrator/analytics/ActiveMerchantsAnalytics"; +import ActiveUsersAnalytics from "../../components/administrator/analytics/ActiveUsersAnalytics"; +import AverageRiskSeriesAnalytics from "../../components/administrator/analytics/AverageRiskSeriesAnalytics"; +import LoginActivityAnalytics from "../../components/administrator/analytics/LoginActivityAnalytics"; +import PendingMerchantsAnalytics from "../../components/administrator/analytics/PendingMerchantsAnalytics"; +import RecentReportsGeneratedAnalytics from "../../components/administrator/analytics/RecentReportsGeneratedAnalytics"; +import UnvalidatedAccountAnalytics from "../../components/administrator/analytics/UnvalidatedAccountAnalytics"; +import UserAccountAnalytics from "../../components/administrator/analytics/UserAccountAnalytics"; +/** + * A route that displays a collection of metrics describing the current + * state of the system, it's users, and the health reports generated. + * + * @returns {@mui.material.Container} + */ const AdministratorDashboard = () => { return ( { return ( { pb: { xs: "2px", sm: "0px", md: "4px" }, pr: { xs: "2px", sm: "0px", md: "4px" }, mr: "0px", - maxWidth: "1600px", + maxWidth: "1600px", }} > diff --git a/client/src/routes/AdministratorUsers.js b/client/src/routes/admin/AdministratorUsers.js similarity index 71% rename from client/src/routes/AdministratorUsers.js rename to client/src/routes/admin/AdministratorUsers.js index 8cfb58a6..bf233254 100644 --- a/client/src/routes/AdministratorUsers.js +++ b/client/src/routes/admin/AdministratorUsers.js @@ -1,12 +1,12 @@ -import UserManagementTable from "../components/administrator/UserManagementTable"; -import { - Box, - Typography, - Divider, - Stack, - Container -} from "@mui/material"; +import { Box, Container, Divider, Stack, Typography } from "@mui/material"; +import UserManagementTable from "../../components/administrator/UserManagementTable"; +/** + * A route that provides tools to manage, filter and search through the users of the + * system. Facilitating the deletion or role changes for an account. + * + * @returns {@mui.material.Container} + */ const AdministratorUsers = () => { return ( { return ( diff --git a/client/src/routes/merchant/MerchantGenerateReport.js b/client/src/routes/merchant/MerchantGenerateReport.js new file mode 100644 index 00000000..8af05ac5 --- /dev/null +++ b/client/src/routes/merchant/MerchantGenerateReport.js @@ -0,0 +1,26 @@ +import { Box } from "@mui/material"; +import MerchantReportForm from "../../components/healthReport/MerchantReportForm"; + +/** + * A page that provides the health report form for merchants to generate + * reports for their patients. + * + * @returns {@mui.material.Container} + */ +const MerchantGenerateReport = () => { + return ( + + + + ); +}; + +export default MerchantGenerateReport; diff --git a/client/src/routes/MerchantLanding.js b/client/src/routes/merchant/MerchantLanding.js similarity index 86% rename from client/src/routes/MerchantLanding.js rename to client/src/routes/merchant/MerchantLanding.js index c253a142..66442eb5 100644 --- a/client/src/routes/MerchantLanding.js +++ b/client/src/routes/merchant/MerchantLanding.js @@ -1,23 +1,27 @@ -import { Box, Typography, Card, CardContent } from "@mui/material"; -import { grid } from "@mui/system"; +import { + Box, + Card, + CardContent, + Typography, + useMediaQuery, + useTheme, +} from "@mui/material"; import { useEffect, useState } from "react"; const API_BASE = process.env.REACT_APP_API_URL || "http://localhost:8000"; +/** + * A page that displays a collections of metrics that summarises the + * history and health data for all of a merchant's patients providing + * a quick overview. + * + * @returns {@mui.material.Container} + */ const MerchantLanding = () => { const [data, setData] = useState({}); - const [name, setName] = useState(""); - useEffect(() => { - fetch(`${API_BASE}/user/me`, { - method: "GET", - credentials: "include", - }) - .then((response) => response.json()) - .then((user) => { - setName(user.name); - }); - }, []); + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down("sm")); useEffect(() => { fetch(`${API_BASE}/merchant-dashboard`, { @@ -49,14 +53,6 @@ const MerchantLanding = () => { }, ]; - function relativeTime(date) { - const diff = (Date.now() - new Date(date)) / 1000; - if (diff < 60) return "just now"; - if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; - if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; - return `${Math.floor(diff / 86400)}d ago`; - } - return ( { }} > - Patient Overview + + Patient Overview + {/* Stat cards */} @@ -108,7 +106,10 @@ const MerchantLanding = () => { > {/* Condition risk graph */} - + Risk Distribution {["stroke", "cvd", "diabetes"].map((condition) => { @@ -187,7 +188,10 @@ const MerchantLanding = () => { p: 2, }} > - + Report Activity @@ -209,7 +213,7 @@ const MerchantLanding = () => { > {a.message} - {relativeTime(a.createdAt)} + {new Date(a.createdAt).toLocaleDateString("en-AU")} )) diff --git a/client/src/routes/MerchantReports.js b/client/src/routes/merchant/MerchantReports.js similarity index 63% rename from client/src/routes/MerchantReports.js rename to client/src/routes/merchant/MerchantReports.js index 5786f6fb..3037db03 100644 --- a/client/src/routes/MerchantReports.js +++ b/client/src/routes/merchant/MerchantReports.js @@ -1,36 +1,38 @@ -import ReportTemplate from "../components/ReportTemplate"; -import DownloadReportButton from "../components/DownloadReportButton"; import CloseIcon from "@mui/icons-material/Close"; -import IconButton from "@mui/material/IconButton"; -import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; -import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp"; - -import ConfirmationDialog from "../components/confirmationDialog"; -import React, { useState, useEffect } from "react"; -import { useLocation } from "react-router-dom"; - import { + Autocomplete, Box, - Typography, + Button, + FormControl, + InputLabel, List, ListItem, ListItemText, MenuItem, - FormControl, - InputLabel, + Paper, Select, - Button, - Autocomplete, TextField, - Drawer, - Stack, - useTheme, + Typography, useMediaQuery, + useTheme, } from "@mui/material"; +import IconButton from "@mui/material/IconButton"; +import PDFHealthChart from "../../components/healthReport/PDFHealthChart"; +import React, { useState, useEffect, useRef, useCallback } from "react"; +import { useLocation } from "react-router-dom"; +import ConfirmationDialog from "../../components/dialog/confirmationDialog"; +import DownloadReportButton from "../../components/healthReport/DownloadReportButton"; +import ReportTemplate from "../../components/healthReport/ReportTemplate"; const API_BASE = process.env.REACT_APP_API_URL || "http://localhost:8000"; -const MerchantReports = ({}) => { +/** + * A page that provides the tools for a merchants to browse through their + * list of patients and view their past history reports. + * + * @returns {@mui.material.Box} + */ +const MerchantReports = () => { const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down("sm")); const location = useLocation(); @@ -47,8 +49,10 @@ const MerchantReports = ({}) => { const [reports, setReports] = useState([]); // Stores all report data const [selectedMonth, setSelectedMonth] = useState(null); const [selectedYear, setSelectedYear] = useState(null); + const chartRef = useRef(null); + const [chartData, setChartData] = useState([]); - function fetchMerchantReports() { + const fetchMerchantReports = useCallback(() => { fetch(`${API_BASE}/merchants/reports`, { credentials: "include", }) @@ -61,9 +65,11 @@ const MerchantReports = ({}) => { .then((data) => { if (data.length > 0) { setReports(data); + // Creates an array of distinct patient names let distinctPatientNames = [...new Set(data.map((r) => r.name))]; setPatients(distinctPatientNames); + setSelectedPatient(defaultSelectedPatientId); setSelectedDate(null); @@ -71,20 +77,21 @@ const MerchantReports = ({}) => { const selectedReports = data.filter( (r) => r.name === defaultSelectedPatientId, ); + setReportDates(selectedReports); setSelectedDate(selectedReports[0]); // Select first report } } }) .catch((err) => { - console.log(err); - }, []); - } + console.error("Failed to fetch report data."); + }); + }, [defaultSelectedPatientId]); // Fetch the merchant reports useEffect(() => { fetchMerchantReports(); - }, []); + }, [fetchMerchantReports]); // Fetch report data useEffect(() => { @@ -97,7 +104,7 @@ const MerchantReports = ({}) => { }) .then((res) => res.json()) .then((data) => setReportData(data)) - .catch((err) => console.log(err)); + .catch((err) => console.error("Failed to fetch report health data.")); }, [selectedDate]); // Delete report data @@ -122,18 +129,42 @@ const MerchantReports = ({}) => { setReportDates(patientReports); setSelectedDate(patientReports[0]); } catch (err) { - console.log(err); + console.error("Failed to delete report data."); } // Close Dialog setDeleteDialogOpen(false); } + // Health Analytics for Chart + useEffect(() => { + if (!selectedDate) { + return; + } + fetch( + `${API_BASE}/health-analytics?health_data_id=${selectedDate.healthDataId}`, + { + credentials: "include", + }, + ) + .then((r) => r.json()) + .then(setChartData) + .catch(console.error); + }, [selectedDate]); + // Extract and sort month and years for drop down. const years = [ ...new Set(reportDates.map((r) => new Date(r.date).getFullYear())), ].sort((a, b) => a - b); + const months = [ - ...new Set(reportDates.map((r) => new Date(r.date).getMonth() + 1)), + ...new Set( + reportDates + .filter( + (r) => + !selectedYear || new Date(r.date).getFullYear() === selectedYear, + ) + .map((r) => new Date(r.date).getMonth() + 1), + ), ].sort((a, b) => a - b); // Filters reports based on selected year and month if any. @@ -158,57 +189,44 @@ const MerchantReports = ({}) => { setReportDates(selectedReports); setSelectedDate(selectedReports[0]); } - const [isOpen, setIsOpen] = useState(false); - function openBar() { - if (isOpen === true) { - setIsOpen(false); - return; - } - setIsOpen(true); - } return ( - {/* Top Bar */} - setIsOpen(false)} - variant="temporary" - PaperProps={{ - sx: { - p: 2, - maxHeight: "50vh", - }, +
- + +
+ + + - - - Report History - - - - - + Report History +
{/* Patient List */} @@ -224,7 +242,17 @@ const MerchantReports = ({}) => { /> - + {/* Year */} Year @@ -264,56 +292,69 @@ const MerchantReports = ({}) => { - - - {filteredReportDates.map((item) => ( - setSelectedDate(item)} - button - sx={{ - py: 2, - px: 3, - borderLeft: - selectedDate?.healthDataId === item.healthDataId - ? "4px solid" - : "4px solid transparent", - borderLeftColor: "primary.main", - bgcolor: - selectedDate?.healthDataId === item.healthDataId - ? "action.selected" - : "transparent", - }} - > - + + {filteredReportDates.map((item) => ( + setSelectedDate(item)} + button + sx={{ + py: 2, + px: 3, + borderLeft: + selectedDate?.healthDataId === item.healthDataId + ? "4px solid" + : "4px solid transparent", + borderLeftColor: "primary.main", + bgcolor: + selectedDate?.healthDataId === item.healthDataId + ? "action.selected" + : "transparent", }} - /> - {/* Delete Report Button */} - {selectedDate?.healthDataId === item.healthDataId && ( - setDeleteDialogOpen(true)} - > - - - )} - - ))} - - - + > + + {`Report: ${new Date(item.date).toLocaleDateString("en-AU")}`} + + {" "} + {`${new Date(item.date).toLocaleTimeString("en-AU")}`} + + + } + /> + {/* Delete Report Button */} + {selectedDate?.healthDataId === item.healthDataId && ( + setDeleteDialogOpen(true)} + > + + + )} + + ))} + +
+
+
+ {/* Report Content */} @@ -324,35 +365,14 @@ const MerchantReports = ({}) => { alignItems: { xs: "stretch", sm: "center" }, gap: 2, p: 2, + justifyContent: "flex-end", }} > - - - View Patient Reports - - - {!isOpen && ( - - - - )} - - {selectedDate && reportData && ( { const API_BASE = process.env.REACT_APP_API_URL || "http://localhost:8000"; @@ -34,7 +45,7 @@ const PatientDetails = () => { .then((data) => { setPatientData(data); }); - }, []); + }, [API_BASE, patientID, navigate]); const chartData = (patientData?.risks?.dates ?? []) .map((date, i) => ({ @@ -71,7 +82,7 @@ const PatientDetails = () => { > { > Patient Details -
+ - + Given Name: - - {" " + patientData?.patientInfo?.givenNames} + {" "} + {patientData?.patientInfo?.givenNames} - + Last Name: - - {" " + patientData?.patientInfo?.familyName} +
{" "} + {patientData?.patientInfo?.familyName} - + Date Of Birth: - - {" " + patientData?.patientInfo?.dateOfBirth} + {" "} + {patientData?.patientInfo?.dateOfBirth} - + Age: - - {" " + patientData?.patientInfo?.age} + {" "} + {patientData?.patientInfo?.age} - + Gender: {" "} - {patientData?.patientInfo?.gender == 1 ? "Male" : "Female"} + {patientData?.patientInfo?.gender === 1 ? "Male" : "Female"} - + + Weight: - - {" " + patientData?.patientInfo?.weight} kg + {" "} + {patientData?.patientInfo?.weight} kg - + + Height: - - {" " + patientData?.patientInfo?.height} cm + {" "} + {patientData?.patientInfo?.height} cm -
- - It has been {patientData?.days} days since{" "} - {patientData?.patientInfo?.givenNames}'s last health report - + + + + It has been {patientData?.days} days since{" "} + {patientData?.patientInfo?.givenNames}'s last health report + - + + @@ -196,29 +213,39 @@ const PatientDetails = () => { sx={{ display: "flex", gap: { xs: 1, sm: 2 }, - flexDirection: "row", + flexDirection: isMobile ? "column" : "row", }} > {["stroke", "diabetes", "cvd"].map((key) => ( - {key.toUpperCase()} - - {patientData?.risks?.[key]?.slice(-1)[0] ?? 0}% + + {patientData?.risks?.[key]?.[0] ?? 0}% { height: { xs: "auto", md: "95%" }, }} > - + Latest Recommendations @@ -297,7 +324,10 @@ const PatientDetails = () => { : "none", }} > - + {key.toUpperCase()} diff --git a/client/src/routes/PatientManagement.js b/client/src/routes/merchant/PatientManagement.js similarity index 90% rename from client/src/routes/PatientManagement.js rename to client/src/routes/merchant/PatientManagement.js index 4901b93e..7018f210 100644 --- a/client/src/routes/PatientManagement.js +++ b/client/src/routes/merchant/PatientManagement.js @@ -1,41 +1,46 @@ -import { useState, useEffect } from "react"; -import { useNavigate } from "react-router-dom"; -import ConfirmationDialog from "../components/confirmationDialog"; -import InputAdornment from "@mui/material/InputAdornment"; import { Box, - Typography, Button, - Paper, - TextField, + Divider, Menu, MenuItem, - Divider, + Paper, Stack, + TextField, + Typography, + useMediaQuery, + useTheme, } from "@mui/material"; +import InputAdornment from "@mui/material/InputAdornment"; import { DataGrid } from "@mui/x-data-grid"; +import { useEffect, useState, useCallback } from "react"; +import { useNavigate } from "react-router-dom"; +import ConfirmationDialog from "../../components/dialog/confirmationDialog"; // Icons import DeleteIcon from "@mui/icons-material/Delete"; -import VisibilityIcon from "@mui/icons-material/Visibility"; import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; import SearchIcon from "@mui/icons-material/Search"; - +import VisibilityIcon from "@mui/icons-material/Visibility"; /** * A page used to display a list of all patients for a merchant user. + * + * @returns {@mui.material.Box} */ const PatientManagement = () => { const API_BASE = process.env.REACT_APP_API_URL || "http://localhost:8000"; const navigate = useNavigate(); + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down("sm")); + const [patientData, setPatientData] = useState([]); const [paginationModel, setPaginationModel] = useState({ page: 0, pageSize: 25, }); const [totalPatients, setTotalPatients] = useState(0); - const [loading, setLoading] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [selectedPatientID, setSelectedPatientID] = useState(null); @@ -107,38 +112,7 @@ const PatientManagement = () => { ), }, ]; - - useEffect(() => { - fetchPatients(); - }, [ - paginationModel.page, - paginationModel.pageSize, - givenNameInput, - familyNameInput, - ]); - - // Debounce given name input - useEffect(() => { - const timer = setTimeout(() => { - // resetting page to 0 when filter changes - setPaginationModel((prev) => ({ ...prev, page: 0 })); - setGivenNameInput(givenNameInput); - }, 500); - return () => clearTimeout(timer); - }, [givenNameInput]); - - // Debounce last name input - useEffect(() => { - const timer = setTimeout(() => { - // resetting page to 0 when filter changes - setPaginationModel((prev) => ({ ...prev, page: 0 })); - setFamilyNameInput(familyNameInput); - }, 500); - return () => clearTimeout(timer); - }, [familyNameInput]); - - const fetchPatients = () => { - setLoading(true); + const fetchPatients = useCallback(() => { const params = new URLSearchParams({ skip: paginationModel.page * paginationModel.pageSize, limit: paginationModel.pageSize, @@ -155,13 +129,17 @@ const PatientManagement = () => { .then((data) => { setPatientData(data.patients || []); setTotalPatients(data.totalPatients || 0); - setLoading(false); }) .catch((err) => { - console.log("An error has occurred"); - setLoading(false); + console.error("An error has occurred."); }); - }; + }, [ + API_BASE, + paginationModel.page, + paginationModel.pageSize, + givenNameInput, + familyNameInput, + ]); async function handleDelete(patientID) { await fetch(`${API_BASE}/remove-patient/${patientID}`, { @@ -170,12 +148,36 @@ const PatientManagement = () => { }) .then((response) => response.json()) .catch((err) => { - console.log("An error has occurred"); + console.error("An error has occurred."); }); fetchPatients(); setDeleteDialogOpen(false); } + useEffect(() => { + fetchPatients(); + }, [fetchPatients]); + + // Debounce given name input + useEffect(() => { + const timer = setTimeout(() => { + // resetting page to 0 when filter changes + setPaginationModel((prev) => ({ ...prev, page: 0 })); + setGivenNameInput(givenNameInput); + }, 500); + return () => clearTimeout(timer); + }, [givenNameInput]); + + // Debounce last name input + useEffect(() => { + const timer = setTimeout(() => { + // resetting page to 0 when filter changes + setPaginationModel((prev) => ({ ...prev, page: 0 })); + setFamilyNameInput(familyNameInput); + }, 500); + return () => clearTimeout(timer); + }, [familyNameInput]); + return ( { }} > - + Patient Management @@ -214,7 +213,16 @@ const PatientManagement = () => { alignItems: { md: "center" }, }} > - + { }} /> { { paginationMode="server" onPaginationModelChange={setPaginationModel} /> - + { + return ( + + + + + + + + ); +}; + +export default RequestPatientAccess; diff --git a/client/src/routes/ErrorPage.js b/client/src/routes/public/ErrorPage.js similarity index 66% rename from client/src/routes/ErrorPage.js rename to client/src/routes/public/ErrorPage.js index 3421526a..0fc4ab8c 100644 --- a/client/src/routes/ErrorPage.js +++ b/client/src/routes/public/ErrorPage.js @@ -1,13 +1,12 @@ -import { - Typography, - Button, - Box, - Stack, -} from "@mui/material"; +import { Box, Button, Stack, Typography } from "@mui/material"; import { useNavigate } from "react-router-dom"; -import Logo from "../assets/WellAiLogoTR.png"; - +/** + * A page that is displayed when a user tried to navigate a non-existent + * or malformed route. + * + * @returns {@mui.material.Container} + */ const ErrorPage = () => { const navigate = useNavigate(); return ( @@ -25,10 +24,7 @@ const ErrorPage = () => { }} > - + Error 404 { sx={{ overflow: "hidden", px: { xs: 2, sm: 8 }, - wordBreak: "break-word" + wordBreak: "break-word", }} > The requested URL cannot be found. - diff --git a/client/src/routes/ForgotPassword.js b/client/src/routes/public/ForgotPassword.js similarity index 68% rename from client/src/routes/ForgotPassword.js rename to client/src/routes/public/ForgotPassword.js index 152e9f04..34d95202 100644 --- a/client/src/routes/ForgotPassword.js +++ b/client/src/routes/public/ForgotPassword.js @@ -1,8 +1,11 @@ import { Box, Container } from "@mui/material"; -import ForgotPasswordForm from "../components/authentication/ForgotPasswordForm"; +import ForgotPasswordForm from "../../components/authentication/ForgotPasswordForm"; /** - * Displays the form to reset a user's password if it has been forgotten. + * A route that provides the form for a user to reset their password by initiating + * the one-time password reset process. + * + * @returns {@mui.material.Container} */ const ForgotPassword = () => { return ( diff --git a/client/src/routes/Login.js b/client/src/routes/public/Login.js similarity index 69% rename from client/src/routes/Login.js rename to client/src/routes/public/Login.js index 9a34eb9e..1f3ed332 100644 --- a/client/src/routes/Login.js +++ b/client/src/routes/public/Login.js @@ -1,11 +1,12 @@ -import { Box, Container, Stack, Typography } from "@mui/material"; -import LoginForm from "../components/authentication/LoginForm"; -import Logo from "../assets/WellAiLogoTR.png"; -import WelcomePanel from "../components/WelcomePanel"; +import { Box, Stack } from "@mui/material"; +import LoginForm from "../../components/authentication/LoginForm"; +import WelcomePanel from "../../components/WelcomePanel"; /** - * Introduces the services and provides forms for logging in to - * the service. + * A page that introduces the service and provides the form for logging + * into the service. + * + * @returns {@mui.material.Box} */ const Login = () => { return ( diff --git a/client/src/routes/Register.js b/client/src/routes/public/Register.js similarity index 72% rename from client/src/routes/Register.js rename to client/src/routes/public/Register.js index 18fe92df..a10f8237 100644 --- a/client/src/routes/Register.js +++ b/client/src/routes/public/Register.js @@ -1,9 +1,10 @@ import { Container } from "@mui/material"; -import RegistrationForm from "../components/authentication/RegistrationForm"; +import RegistrationForm from "../../components/authentication/RegistrationForm"; /** - * A page used to display registration information and provide a form to allow - * users to register. + * A page that displays the registration form. + * + * @returns {@mui.material.Container} */ const Register = () => { return ( diff --git a/client/src/routes/ResetPassword.js b/client/src/routes/public/ResetPassword.js similarity index 68% rename from client/src/routes/ResetPassword.js rename to client/src/routes/public/ResetPassword.js index 61f3064b..c61123e8 100644 --- a/client/src/routes/ResetPassword.js +++ b/client/src/routes/public/ResetPassword.js @@ -1,8 +1,11 @@ import { Container } from "@mui/material"; -import ResetPasswordForm from "../components/authentication/ResetPasswordForm"; +import ResetPasswordForm from "../../components/authentication/ResetPasswordForm"; /** - * Displays the form to reset a user's password if it has been forgotten. + * A page that displays the form to reset a user's password using a one-time + * password reset token. + * + * @return {@mui.material.Container} */ const ResetPassword = () => { return ( diff --git a/client/src/routes/standardUser/AcceptAccessRequest.js b/client/src/routes/standardUser/AcceptAccessRequest.js new file mode 100644 index 00000000..1508e057 --- /dev/null +++ b/client/src/routes/standardUser/AcceptAccessRequest.js @@ -0,0 +1,41 @@ +import { Box, Container } from "@mui/material"; +import AcceptRequestForm from "../../components/patientAccessRequest/AcceptRequestForm"; + +/** + * A page that displays the accept access request form for users to + * accept requests from merchants. + * + * @returns {@mui.material.Container} + */ +const AcceptAccessRequest = () => { + return ( + + + + + + + + ); +}; + +export default AcceptAccessRequest; diff --git a/client/src/routes/GenerateReport.js b/client/src/routes/standardUser/GenerateReport.js similarity index 55% rename from client/src/routes/GenerateReport.js rename to client/src/routes/standardUser/GenerateReport.js index 552ab5d2..25f95b28 100644 --- a/client/src/routes/GenerateReport.js +++ b/client/src/routes/standardUser/GenerateReport.js @@ -1,7 +1,12 @@ -import GenerateReportForm from "../components/GenerateReportForm"; import { Box } from "@mui/material"; +import GenerateReportForm from "../../components/healthReport/GenerateReportForm"; -const GenerateReport = ({}) => { +/** + * A page that provides the form for user's to generate a health report. + * + * @returns {@mui.material.Box} + */ +const GenerateReport = () => { return ( { if (!label) { @@ -49,6 +49,12 @@ const formatXAxisLabel = (label, index, total, isMobile) => { return label; }; +/** + * A page that displays the trends in the user's health based on + * their report history. + * + * @returns {@mui.material.Box} + */ const HealthAnalytics = () => { const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down("sm")); @@ -76,10 +82,9 @@ const HealthAnalytics = () => { throw new Error("Network response was not ok"); } const data = await response.json(); - //console.log("Fetched data:", data); // Log fetched data setHealthData(data); } catch (error) { - console.error("Failed to fetch health analytics data:", error); + console.error("Failed to fetch health analytics data."); } }; @@ -101,7 +106,10 @@ const HealthAnalytics = () => { }; // Helpers for aggregation and y-axis scaling - const colors = [theme.palette.primary.main, "#ff7043", "#42a5f5"]; + const colors = useMemo( + () => [theme.palette.primary.main, "#ff7043", "#42a5f5"], + [theme.palette.primary.main], + ); const cardWrapperSx = useMemo( () => ({ @@ -284,16 +292,12 @@ const HealthAnalytics = () => { boxSizing: "border-box", }} > - - - Health Analytics - - + + Health Analytics + @@ -305,9 +309,10 @@ const HealthAnalytics = () => { mb: showSettings ? 2 : 0, }} > - + Probability Metrics Selection + {isMobile && ( setShowSettings(!showSettings)} @@ -352,7 +357,9 @@ const HealthAnalytics = () => { control={ handleMetricChange("diabetesProbability")} + onChange={() => + handleMetricChange("diabetesProbability") + } sx={{ color: colors[2], "&.Mui-checked": { color: colors[2] }, @@ -369,7 +376,7 @@ const HealthAnalytics = () => { - + Health Risk Trends Over Time {chartSeries.length > 0 && healthData.length > 0 ? ( @@ -456,7 +463,8 @@ const HealthAnalytics = () => { > + ); } else { return ( - setIsOpen(false)} - variant="temporary" - PaperProps={{ - sx: { - p: 2, - maxHeight: "50vh", - }, +
+ +
+ + + + + Report History + + + + {/* Date Select */} - - + Year + { - setSelectedYear(e.target.value); - setSelectedMonth(null); - }} - > - {years.map((year) => ( - - {year} - - ))} - - - - {/* Month */} - - Month - - + {years.map((year) => ( + + {year} + + ))} + + - - + {/* Month */} + + Month + + + + + {filteredReportDates.map((item) => ( { }} > {`Report: ${new Date(item.date).toLocaleDateString("en-AU")}`} + + {" "} + {`${new Date(item.date).toLocaleTimeString("en-AU")}`} +
} /> + {/* Delete Report Button */} {selectedDate.healthDataId === item.healthDataId && ( { ))}
- + {/* Menu and Download Buttons */} @@ -295,26 +338,9 @@ const AIHealthPrediction = ({}) => { p: 2, }} > - - - View Report History - - - {!isOpen && ( - - - - )} - - { - const navigate = useNavigate(); +/** + * A page that displays a collection of metrics based on the user's data. + * + * @returns {@mui.material.Box} + */ +const UserLanding = () => { const [name, setName] = useState(null); const [data, setData] = useState({}); @@ -106,7 +116,7 @@ const UserLanding = ({}) => { @@ -125,7 +135,7 @@ const UserLanding = ({}) => { sx={{ display: "flex", gap: { xs: 1, sm: 2 }, - flexDirection: "row", + flexDirection: isMobile ? "column" : "row", }} > {["stroke", "diabetes", "cvd"].map((key) => ( @@ -157,7 +167,7 @@ const UserLanding = ({}) => { lineHeight: 1.2, }} > - {data?.risks?.[key]?.slice(-1)[0] ?? 0}% + {data?.risks?.[key]?.[0] ?? 0}%
{ height: { xs: "auto", md: "95%" }, }} > - + Latest Recommendations @@ -234,7 +244,10 @@ const UserLanding = ({}) => { : "none", }} > - + {key.toUpperCase()} diff --git a/client/src/routes/UserSettings.js b/client/src/routes/standardUser/UserSettings.js similarity index 74% rename from client/src/routes/UserSettings.js rename to client/src/routes/standardUser/UserSettings.js index e69a95b0..6a26ef67 100644 --- a/client/src/routes/UserSettings.js +++ b/client/src/routes/standardUser/UserSettings.js @@ -1,30 +1,35 @@ -import React, { useContext, useEffect, useMemo, useState } from "react"; import { + Alert, Box, - Typography, - List, - ListItem, - ListItemText, - TextField, - Divider, Button, - Switch, - FormControlLabel, FormControl, InputLabel, - Select, + List, + ListItem, + ListItemText, MenuItem, - Alert, + Select, Stack, - useTheme, - useMediaQuery, - Tabs, Tab, + Tabs, + TextField, + Typography, + useMediaQuery, + useTheme, } from "@mui/material"; -import ConfirmationDialog from "../components/confirmationDialog"; +import { useContext, useEffect, useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; -import { UserContext } from "../utils/UserContext"; - +import PhoneInputField from "../../components/authentication/PhoneInputField"; +import ConfirmationDialog from "../../components/dialog/confirmationDialog"; +import { UserContext } from "../../utils/UserContext"; +import { stringEqual } from "../../utils/stringEqual"; + +/** + * A page that provides the tools for a user to securely update their + * account and personal details. + * + * @returns {@mui.material.Box} + */ const UserSettings = () => { const navigate = useNavigate(); const { @@ -59,16 +64,6 @@ const UserSettings = () => { const [passwordChanged, setPasswordChanged] = useState(false); - // Notification state - const [notifications, setNotifications] = useState({ - emailNotifications: true, - smsNotifications: false, - pushNotifications: true, - healthReminders: true, - reportUpdates: true, - systemAlerts: false, - }); - const [saveMessage, setSaveMessage] = useState(""); const [saveError, setSaveError] = useState(""); const [profileErrors, setProfileErrors] = useState({}); @@ -78,6 +73,9 @@ const UserSettings = () => { const [deleteBusy, setDeleteBusy] = useState(false); const [deleteError, setDeleteError] = useState(""); + const [isPhoneValid, setIsPhoneValid] = useState(true); + const [rawPhoneDigits, setRawPhoneDigits] = useState(""); + // Resolve API base from environment variable const API_BASE = useMemo( () => process.env.REACT_APP_API_URL || "http://localhost:8000", @@ -103,6 +101,7 @@ const UserSettings = () => { height: user.height ?? "", weight: user.weight ?? "", })); + setRawPhoneDigits(user.phone_number || ""); }, [user]); const clearMessages = () => { @@ -155,18 +154,12 @@ const UserSettings = () => { return response.json(); }) .catch((error) => { - console.log(error); + console.error("Failed to change password."); }); } const updateForm = (k, v) => setFormData((p) => ({ ...p, [k]: v })); const updatePwd = (k, v) => setPasswordData((p) => ({ ...p, [k]: v })); - const updateNotify = (k, v) => setNotifications((p) => ({ ...p, [k]: v })); - - const handleSave = (section) => { - setSaveMessage(`${section} saved successfully!`); - setTimeout(() => setSaveMessage(""), 2500); - }; const validateProfile = () => { const errors = {}; @@ -209,6 +202,11 @@ const UserSettings = () => { const handleAccountSave = async () => { clearMessages(); + if (!isPhoneValid) { + setSaveError("Please provide a valid phone number."); + return; + } + setAccountSaving(true); try { const phone = formData.phone ?? ""; @@ -264,8 +262,8 @@ const UserSettings = () => { const AccountDetails = () => ( Account Details @@ -295,12 +293,13 @@ const UserSettings = () => { helperText="Email update is currently not supported" fullWidth /> - updateForm("phone", e.target.value)} - helperText="Only digits will be stored" - fullWidth + { + updateForm("phone", e.phone); + setIsPhoneValid(e.isValid); + setRawPhoneDigits(e.rawDigits); + }} + value={rawPhoneDigits} /> @@ -322,37 +321,24 @@ const UserSettings = () => { justifyContent: "space-between", gap: 1, flexWrap: "wrap", + flexDirection: isMobile ? "column" : "row", }} > - - Danger Zone - {deleteError && ( - + {deleteError} )} @@ -377,7 +363,6 @@ const UserSettings = () => { width: { xs: "min(260px, 100%)", sm: "auto" }, py: { xs: "0.7rem", sm: "0.6rem" }, fontSize: { xs: "0.95rem", sm: "0.875rem" }, - fontWeight: 500, }} > {accountSaving ? "Saving…" : "Save Changes"} @@ -410,8 +395,8 @@ const UserSettings = () => { const Profile = () => ( Profile Settings @@ -426,15 +411,6 @@ const UserSettings = () => { )} - - - {user?.given_names || ""} {user?.family_name || ""} - - - {user?.email || ""} - - - { const Password = () => { const mismatch = passwordData.confirmPassword && - passwordData.newPassword !== passwordData.confirmPassword; + !stringEqual(passwordData.newPassword, passwordData.confirmPassword); const disabled = !passwordData.currentPassword || !passwordData.newPassword || mismatch; return ( Change Password @@ -594,115 +570,6 @@ const UserSettings = () => { ); }; - const Notifications = () => ( - - - Notification Settings - - {saveMessage && ( - - {saveMessage} - - )} - - - - updateNotify("emailNotifications", e.target.checked) - } - /> - } - label="Email Notifications" - /> - - Receive reminders and updates via email - - - - updateNotify("pushNotifications", e.target.checked) - } - /> - } - label="Push Notifications" - /> - - Get instant notifications on your device - - - - updateNotify("smsNotifications", e.target.checked) - } - /> - } - label="SMS Notifications" - /> - - Receive important health alerts via SMS - - - - - updateNotify("healthReminders", e.target.checked) - } - /> - } - label="Health Reminders" - /> - updateNotify("reportUpdates", e.target.checked)} - /> - } - label="Report Updates" - /> - updateNotify("systemAlerts", e.target.checked)} - /> - } - label="System Alerts" - /> - - - - - - - ); - const handleTabChange = (event, newValue) => { setSelectedSection(newValue); }; @@ -715,8 +582,6 @@ const UserSettings = () => { return Profile(); case "Password": return Password(); - case "Notifications": - return Notifications(); default: return null; } @@ -758,7 +623,6 @@ const UserSettings = () => { - ) : ( @@ -776,39 +640,37 @@ const UserSettings = () => { - {["Account Details", "Profile", "Password", "Notifications"].map( - (item) => ( - setSelectedSection(item)} - sx={{ - py: 2, - px: 3, - borderLeft: - selectedSection === item - ? "4px solid" - : "4px solid transparent", - borderLeftColor: "primary.main", - bgcolor: - selectedSection === item - ? "action.selected" - : "transparent", - "&:hover": { - bgcolor: "action.hover", - }, + {["Account Details", "Profile", "Password"].map((item) => ( + setSelectedSection(item)} + sx={{ + py: 2, + px: 3, + borderLeft: + selectedSection === item + ? "4px solid" + : "4px solid transparent", + borderLeftColor: "primary.main", + bgcolor: + selectedSection === item + ? "action.selected" + : "transparent", + "&:hover": { + bgcolor: "action.hover", + }, + }} + > + - - - ), - )} + /> + + ))} )} diff --git a/client/src/utils/LandingRoute.js b/client/src/utils/LandingRoute.js index 47cc01dd..7a72b836 100644 --- a/client/src/utils/LandingRoute.js +++ b/client/src/utils/LandingRoute.js @@ -1,8 +1,13 @@ -import { Navigate } from "react-router-dom"; import { useEffect, useState } from "react"; +import { Navigate } from "react-router-dom"; const API_BASE = process.env.REACT_APP_API_URL || "http://localhost:8000"; +/** + * Reroutes a user to the landing page that corresponds to their role. + * + * @returns {react-router-dom.Navigate} + */ const LandingRoute = () => { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); diff --git a/client/src/utils/ProtectedRoutes.js b/client/src/utils/ProtectedRoutes.js index 7dc84b3f..9dff833a 100644 --- a/client/src/utils/ProtectedRoutes.js +++ b/client/src/utils/ProtectedRoutes.js @@ -1,9 +1,18 @@ -import { Outlet, Navigate, useLocation } from "react-router-dom"; import { useEffect, useState } from "react"; +import { Navigate, Outlet, useLocation } from "react-router-dom"; import NavBar from "../components/Navbar"; const API_BASE = process.env.REACT_APP_API_URL || "http://localhost:8000"; +/** + * Only grants access to components nested within to user's with the + * specified role. If the user is not logged when attempting to navigate to + * a protected route, they will be redirected once they log in. + * + * @param {Object} props + * @param {string} [props.role] - The role of a user + * @returns { empty | react-router-dom.Navigate} + */ const ProtectedRoutes = ({ role }) => { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); diff --git a/client/src/utils/PublicOnlyRoutes.js b/client/src/utils/PublicOnlyRoutes.js index 9e505d7b..77a87c94 100644 --- a/client/src/utils/PublicOnlyRoutes.js +++ b/client/src/utils/PublicOnlyRoutes.js @@ -1,12 +1,16 @@ -import { Outlet, Navigate } from "react-router-dom"; import { useEffect, useState } from "react"; -import NavBar from "../components/Navbar"; +import { Navigate, Outlet } from "react-router-dom"; const API_BASE = process.env.REACT_APP_API_URL || "http://localhost:8000"; -const PublicOnlyRoutes = ({ role }) => { +/** + * Only grants access to child components when a user is not logged in + * the website. + * + * @returns {react-router-dom.Navigate | react-router-dom.Outlet} + */ +const PublicOnlyRoutes = () => { const [user, setUser] = useState(null); - const [loading, setLoading] = useState(true); useEffect(() => { fetch(`${API_BASE}/user/me`, { diff --git a/client/src/utils/UserContext.js b/client/src/utils/UserContext.js index 7d291519..66835553 100644 --- a/client/src/utils/UserContext.js +++ b/client/src/utils/UserContext.js @@ -1,13 +1,20 @@ import { createContext, - useState, + useCallback, useEffect, useMemo, - useCallback, + useState, } from "react"; export const UserContext = createContext(null); +/** + * A utility used to securely share information across the application. + * + * @param {Object} props + * @param {object} [props.children] - child elements nested in this element + * @returns {UserContext} + */ export const UserProvider = ({ children }) => { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); diff --git a/client/src/utils/stringEqual.js b/client/src/utils/stringEqual.js new file mode 100644 index 00000000..019f51cf --- /dev/null +++ b/client/src/utils/stringEqual.js @@ -0,0 +1,18 @@ +/** + * Compares two strings in constant time using XOR. + * + * @param {string} a - The first string to compare + * @param {string} b - The second string to compare + * + * @returns {boolean} True if the strings are equal, false otherwise + */ +export function stringEqual(a, b) { + if (a.length !== b.length) { + return false; + } + let diff = 0; + for (let i = 0; i < a.length; i++) { + diff |= a.charCodeAt(i) ^ b.charCodeAt(i); + } + return diff === 0; +} diff --git a/load-tests/scenarios/admin.js b/load-tests/scenarios/admin.js index 7088e7d7..1b758e45 100644 --- a/load-tests/scenarios/admin.js +++ b/load-tests/scenarios/admin.js @@ -2,14 +2,16 @@ import http from 'k6/http'; import { check, sleep } from 'k6'; -const BASE_URL = 'http://localhost:8000'; +const BASE_URL = __ENV.BASEURL || 'http://localhost:8000'; +const TEST_EMAIL = __ENV.TEST_EMAIL; +const TEST_PASSWORD = __ENV.TEST_PASSWORD; export default function () { // Test login. let loginResult = http.post(`${BASE_URL}/login`, JSON.stringify({ - email: 'SHP_Admin@example.com', - password: 'password12345678', + email: TEST_EMAIL, + password: TEST_PASSWORD, }), { headers: { 'Content-Type': 'application/json' }, }); diff --git a/load-tests/scenarios/merchant.js b/load-tests/scenarios/merchant.js index 163fb320..fe537e53 100644 --- a/load-tests/scenarios/merchant.js +++ b/load-tests/scenarios/merchant.js @@ -1,15 +1,18 @@ import http from "k6/http"; import { check, sleep } from "k6"; -const BASE_URL = "http://localhost:8000"; + +const BASE_URL = __ENV.BASEURL || 'http://localhost:8000'; +const TEST_EMAIL = __ENV.TEST_EMAIL; +const TEST_PASSWORD = __ENV.TEST_PASSWORD; export default function () { // Test login. let loginResult = http.post( `${BASE_URL}/login`, JSON.stringify({ - email: "service@example.com", - password: "thisismypassword", + email: TEST_EMAIL, + password: TEST_PASSWORD, }), { headers: { "Content-Type": "application/json" }, diff --git a/load-tests/scenarios/user.js b/load-tests/scenarios/user.js index 4e2b0941..6b042f38 100644 --- a/load-tests/scenarios/user.js +++ b/load-tests/scenarios/user.js @@ -1,15 +1,18 @@ import http from "k6/http"; import { check, sleep } from "k6"; -const BASE_URL = "http://localhost:8000"; + +const BASE_URL = __ENV.BASEURL || 'http://localhost:8000'; +const TEST_EMAIL = __ENV.TEST_EMAIL; +const TEST_PASSWORD = __ENV.TEST_PASSWORD; export default function () { // Test login. let loginResult = http.post( `${BASE_URL}/login`, JSON.stringify({ - email: "audrey.young@example.com", - password: "whyaretherebirds", + email: TEST_EMAIL, + password: TEST_PASSWORD, }), { headers: { "Content-Type": "application/json" }, diff --git a/server/alembic/versions/c173c206403b_add_race_to_prediction_and_patient_table.py b/server/alembic/versions/c173c206403b_add_race_to_prediction_and_patient_table.py new file mode 100644 index 00000000..58f736bd --- /dev/null +++ b/server/alembic/versions/c173c206403b_add_race_to_prediction_and_patient_table.py @@ -0,0 +1,42 @@ +"""Add race to prediction and patient table + +Revision ID: c173c206403b +Revises: d89af5748b27 +Create Date: 2026-05-19 19:32:41.936172 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision: str = 'c173c206403b' +down_revision: Union[str, Sequence[str], None] = 'd89af5748b27' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('HealthData', sa.Column('Race', sa.Integer(), nullable=True)) + op.alter_column('HealthData', 'Alcohol', + existing_type=mysql.TINYINT(display_width=1), + type_=sa.Integer(), + existing_nullable=True) + op.add_column('Patient', sa.Column('Race', sa.Integer(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('Patient', 'Race') + op.alter_column('HealthData', 'Alcohol', + existing_type=sa.Integer(), + type_=mysql.TINYINT(display_width=1), + existing_nullable=True) + op.drop_column('HealthData', 'Race') + # ### end Alembic commands ### diff --git a/server/alembic/versions/d5c83d9c9af7_remove_unused_tables.py b/server/alembic/versions/d5c83d9c9af7_remove_unused_tables.py new file mode 100644 index 00000000..0d6e6649 --- /dev/null +++ b/server/alembic/versions/d5c83d9c9af7_remove_unused_tables.py @@ -0,0 +1,51 @@ +"""Remove unused tables + +Revision ID: d5c83d9c9af7 +Revises: c173c206403b +Create Date: 2026-05-24 18:37:06.230856 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision: str = 'd5c83d9c9af7' +down_revision: Union[str, Sequence[str], None] = 'c173c206403b' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('RolePermission') + op.drop_table('Permission') + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('Permission', + sa.Column('PermissionID', mysql.INTEGER(), autoincrement=True, nullable=False), + sa.Column('PermissionName', mysql.VARCHAR(length=100), nullable=True), + sa.PrimaryKeyConstraint('PermissionID'), + mysql_collate='utf8mb4_0900_ai_ci', + mysql_default_charset='utf8mb4', + mysql_engine='InnoDB' + ) + op.create_table('RolePermission', + sa.Column('RoleID', mysql.INTEGER(), autoincrement=False, nullable=False), + sa.Column('PermissionID', mysql.INTEGER(), autoincrement=False, nullable=False), + sa.Column('AssignedAt', mysql.DATETIME(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=True), + sa.ForeignKeyConstraint(['PermissionID'], ['Permission.PermissionID'], name=op.f('RolePermission_ibfk_1')), + sa.ForeignKeyConstraint(['RoleID'], ['AccountRole.RoleID'], name=op.f('RolePermission_ibfk_2')), + sa.PrimaryKeyConstraint('RoleID', 'PermissionID'), + mysql_collate='utf8mb4_0900_ai_ci', + mysql_default_charset='utf8mb4', + mysql_engine='InnoDB' + ) + # ### end Alembic commands ### diff --git a/server/alembic/versions/d89af5748b27_add_marital_and_working_status_to_.py b/server/alembic/versions/d89af5748b27_add_marital_and_working_status_to_.py new file mode 100644 index 00000000..1305ae4f --- /dev/null +++ b/server/alembic/versions/d89af5748b27_add_marital_and_working_status_to_.py @@ -0,0 +1,34 @@ +"""Add marital and working status to patient + +Revision ID: d89af5748b27 +Revises: b1d8c21fe405 +Create Date: 2026-05-15 22:18:17.182065 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'd89af5748b27' +down_revision: Union[str, Sequence[str], None] = 'b1d8c21fe405' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('Patient', sa.Column('MaritalStatus', sa.Integer(), nullable=True)) + op.add_column('Patient', sa.Column('WorkingStatus', sa.Integer(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('Patient', 'WorkingStatus') + op.drop_column('Patient', 'MaritalStatus') + # ### end Alembic commands ### diff --git a/server/main.py b/server/main.py index 52a10c07..14969994 100644 --- a/server/main.py +++ b/server/main.py @@ -1,3 +1,9 @@ +"""FastAPI application entry point for the Smart Health Predictive API. + +Includes router modules for health predictions, authentication, user +management, and admin operations. CORS is configured from environment +variable ``CORS_ORIGINS`` or a default set of development origins. +""" import os from fastapi import FastAPI @@ -31,6 +37,7 @@ @app.get("/") async def root(): + """Health-check endpoint; returns a simple greeting.""" return {"message": "Hello World"} app.include_router(health_prediction.router) diff --git a/server/models/dbmodels.py b/server/models/dbmodels.py index f2b64627..d5a3b26a 100644 --- a/server/models/dbmodels.py +++ b/server/models/dbmodels.py @@ -2,6 +2,40 @@ from sqlalchemy.orm import declarative_base, relationship import enum +MARITAL_STATUS_OPTIONS = { + 0: 'Single', + 1: 'Married', + 2: 'Widow', + 3: 'Divorced' +} + +WORKING_STATUS_OPTIONS = { + 0: 'Unemployed', + 1: 'Homemaker', + 2: 'Student', + 3: 'Working', + 4: 'Retired', +} + +RACE_OPTIONS = { + 0: 'Malay', + 1: 'Chinese', + 2: 'Indian', + 3: 'Other' +} + +SMOKER_OPTIONS = { + 0: 'No', + 1: 'Yes', + 2: 'Former smoker' +} + +ALCOHOL_OPTIONS = { + 0: 'Regular', + 1: 'Occasional', + 2: 'Non-drinker' +} + Base = declarative_base() @@ -54,7 +88,6 @@ class AccountRole(Base): RoleName = Column(String(100)) userRoles = relationship("UserAccountRole", back_populates="role") - rolePermissions = relationship("RolePermission", back_populates="role") def __init__(self, RoleName): self.RoleName = RoleName @@ -63,21 +96,6 @@ def __repr__(self): return f'AccountRole(RoleID={self.RoleID}, RoleName={self.RoleName})' -class Permission(Base): - __tablename__ = 'Permission' - PermissionID = Column(Integer, primary_key=True) - PermissionName = Column(String(100)) - - permissionRoles = relationship( - "RolePermission", back_populates="permission") - - def __init__(self, PermissionName): - self.PermissionName = PermissionName - - def __repr__(self): - return f'Permission(PermissionID={self.PermissionID}, PermissionName={self.PermissionName})' - - class UserAccountRole(Base): __tablename__ = 'UserAccountRole' RoleID = Column(Integer, ForeignKey( @@ -93,28 +111,8 @@ def __init__(self, RoleID, UserID): self.RoleID = RoleID self.UserID = UserID - def __repr(self): - return f'UserAccountRole(RoleID={self.RoleID}, UserID={self.UserID}, \ - AssignedAt={self.AssignedAt})' - - -class RolePermission(Base): - __tablename__ = 'RolePermission' - RoleID = Column(Integer, ForeignKey( - "AccountRole.RoleID"), primary_key=True) - PermissionID = Column(Integer, ForeignKey( - "Permission.PermissionID"), primary_key=True) - AssignedAt = Column(DateTime, server_default=text("CURRENT_TIMESTAMP")) - - role = relationship("AccountRole", back_populates="rolePermissions") - permission = relationship("Permission", back_populates="permissionRoles") - - def __init__(self, RoleID, PermissionID): - self.RoleID = RoleID - self.PermissionID = PermissionID - def __repr__(self): - return f'RolePermission(RoleID={self.RoleID}, PermissionID={self.PermissionID}, \ + return f'UserAccountRole(RoleID={self.RoleID}, UserID={self.UserID}, \ AssignedAt={self.AssignedAt})' @@ -130,12 +128,27 @@ class Patient(Base): Height = Column(Numeric(5, 2)) DateOfBirth = Column(Date) CreatedAt = Column(DateTime, server_default=text('CURRENT_TIMESTAMP')) + MaritalStatus = Column(Integer) + WorkingStatus = Column(Integer) + Race = Column(Integer) user = relationship("UserAccount", back_populates="patients") health_records = relationship("HealthData", back_populates="patient") user_access = relationship("UserPatientAccess", back_populates="patient") - def __init__(self, user_id, given_names, family_name, gender, weight, height, date_of_birth): + def __init__( + self, + user_id, + given_names, + family_name, + gender, + weight, + height, + date_of_birth, + marital_status=None, + working_status=None, + race=None + ): self.UserID = user_id self.GivenNames = given_names self.FamilyName = family_name @@ -143,12 +156,54 @@ def __init__(self, user_id, given_names, family_name, gender, weight, height, da self.Weight = weight self.Height = height self.DateOfBirth = date_of_birth + self.MaritalStatus = marital_status + self.WorkingStatus = working_status + self.race = race def __repr__(self): return f'Patient(PatientID={self.PatientID}, UserID={self.UserID}, givenNames={self.GivenNames}, \ familyName={self.FamilyName}, gender={self.Gender}, weight={self.Weight}, height={self.Height}, \ dateOfBirth={self.DateOfBirth}, Created={self.CreatedAt})' + def get_marital_status(self): + """Returns marital status as a string.""" + return MARITAL_STATUS_OPTIONS.get(self.MaritalStatus) + + def get_working_status(self): + """Returns working status as a string.""" + return WORKING_STATUS_OPTIONS.get(self.WorkingStatus) + + def get_race(self): + """Returns race as a string.""" + return RACE_OPTIONS.get(self.Race) + + def set_marital_status(self, status: int | str): + """Sets the value of the marital status and will convert a string + to the matching integer.""" + if isinstance(status, str): + self.MaritalStatus = next( + (k for k, v in MARITAL_STATUS_OPTIONS.items() if v == status), None) + else: + self.MaritalStatus = status + + def set_working_status(self, status: int | str): + """Sets the value of the working status and will convert a string + to the matching integer.""" + if isinstance(status, str): + self.WorkingStatus = next( + (k for k, v in WORKING_STATUS_OPTIONS.items() if v == status), None) + else: + self.WorkingStatus = status + + def set_race(self, race: int | str): + """Sets the value of the race and will convert a string + to the matching integer.""" + if isinstance(race, str): + self.Race = next( + (k for k, v in RACE_OPTIONS.items() if v == race), None) + else: + self.Race = race + class UserPatientAccess(Base): __tablename__ = 'UserPatientAccess' @@ -187,12 +242,13 @@ class HealthData(Base): HyperTension = Column(Boolean) HeartDisease = Column(Boolean) Diabetes = Column(Boolean) - Alcohol = Column(Boolean) + Alcohol = Column(Integer) SmokingStatus = Column(Integer) MaritalStatus = Column(Integer) WorkingStatus = Column(Integer) CreatedAt = Column(DateTime, server_default=text('CURRENT_TIMESTAMP')) Stroke = Column(Integer) + Race = Column(Integer) patient = relationship("Patient", back_populates="health_records") predictions = relationship("Prediction", back_populates="health_data") @@ -201,7 +257,7 @@ class HealthData(Base): def __init__(self, PatientID, age, weight, height, gender, bloodGlucose, ap_hi, ap_lo, highCholesterol, hyperTension, heartDisease, - diabetes, alcohol, smoker, maritalStatus, workingStatus, stroke): + diabetes, alcohol, smoker, maritalStatus, workingStatus, stroke, race): self.PatientID = PatientID self.Age = age self.WeightKilograms = weight @@ -218,7 +274,8 @@ def __init__(self, PatientID, age, weight, height, gender, bloodGlucose, ap_hi, self.SmokingStatus = smoker self.MaritalStatus = maritalStatus self.WorkingStatus = workingStatus - self.Stroke = stroke + self.Stroke = stroke, + self.Race = race def __repr__(self): return f'HealthData(HealthDataID = {self.HealthDataID}, UserID={self.UserID}, age={self.Age}, weight={self.WeightKilograms}, \ @@ -226,7 +283,7 @@ def __repr__(self): ap_hi={self.APHigh}, ap_lo={self.APLow}, highCholesterol={self.HighCholesterol}, \ hyperTension={self.HyperTension}, heartDisease={self.HeartDisease}, \ diabetes={self.Diabetes}, alcohol={self.Alcohol}, smoker={self.SmokingStatus}, \ - maritalStatus={self.MaritalStatus}, workingStatus={self.WorkingStatus}, Created={self.CreatedAt}, Stroke={self.Stroke} )' + maritalStatus={self.MaritalStatus}, workingStatus={self.WorkingStatus}, race={self.Race}, Created={self.CreatedAt}, Stroke={self.Stroke} )' class Prediction(Base): diff --git a/server/routers/admin.py b/server/routers/admin.py index 76396b3e..84797909 100644 --- a/server/routers/admin.py +++ b/server/routers/admin.py @@ -93,7 +93,22 @@ async def get_users( sort_order: Optional[str] = "desc", db_conn: Session = Depends(get_db) ): - """Return all user accounts""" + """ + Return a paginated, filterable, sortable list of validated user accounts. + + Supports searching by email, patient name, or clinic name; filtering by + clinic; and sorting by email, creation date, or validation status. + + :param skip: Number of records to skip (offset). + :param limit: Maximum number of records to return. + :param search: Optional keyword to search across email, name, and clinic. + :param clinic_id: Optional clinic ID to filter users. + :param sort_by: Column name to sort by (email, createdAt, validated). + :param sort_order: Sort direction (asc or desc). + :param db_conn: Database session provided by the FastAPI dependency. + :return: A dict with ``users`` (list) and ``total`` (count). + :raises HTTPException 400: If ``skip`` is negative or ``limit`` is not positive. + """ if skip < 0: raise HTTPException( @@ -188,7 +203,49 @@ async def get_users( @router.patch("/users/{user_email}/roles/{role_id}") async def update_user_role(user_email: str, role_id: int, request: Request, db_conn: Session = Depends(get_db)): - """Update a user's role""" + """ + Update a user's role to the specified role ID. + + Verifies the requesting user is an administrator, prevents self-role + changes, and creates or updates the ``UserAccountRole`` mapping. + An audit log is written on both success and failure. + + :param user_email: The email of the target user whose role will change. + :param role_id: The ``RoleID`` to assign to the user. + :param request: The HTTP request (for current-user extraction and audit logging). + :param db_conn: Database session provided by the FastAPI dependency. + :return: A dict with a success message and the assigned role info. + :raises HTTPException 401: If the requesting user cannot be identified. + :raises HTTPException 403: If the requesting user is not an admin. + :raises HTTPException 404: If the user or role is not found. + :raises HTTPException 400: If an admin attempts to change their own role. + """ + # Authenticate the requesting user. + current_user_data = get_current_user(request, db_conn) + requesting_user_email = current_user_data.get('email') + + # Verify the requesting user is an administrator. + admin_user = db_conn.query(UserAccount).filter( + UserAccount.Email == requesting_user_email).first() + if not admin_user: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not identify the requesting user.") + + admin_role = db_conn.query(AccountRole).join( + UserAccountRole, AccountRole.RoleID == UserAccountRole.RoleID + ).filter(UserAccountRole.UserID == admin_user.UserID).first() + + if not admin_role or admin_role.RoleName.lower() != 'admin': + write_audit_log(db_conn, + eventType=LogEventType.ROLE_CHANGED, + success=False, + userEmail=requesting_user_email, + device=request.headers.get("user-agent"), + ipAddress=request.client.host, + description=f"Unauthorized role change attempt for account: {user_email}.") + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, + detail="You do not have permission to change user roles.") + # Check if both the user and role exist. user = db_conn.query(UserAccount).filter( UserAccount.Email == user_email).first() @@ -196,6 +253,11 @@ async def update_user_role(user_email: str, role_id: int, request: Request, raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="User not found.") + # Prevent admin from changing their own role. + if admin_user.UserID == user.UserID: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, + detail="Administrators cannot change their own role.") + role = db_conn.query(AccountRole).filter( AccountRole.RoleID == role_id).first() if not role: @@ -214,16 +276,10 @@ async def update_user_role(user_email: str, role_id: int, request: Request, db_conn.commit() - actor_email = None - try: # After resolving the authentication issue for this endpoint, this exception handling should be removed - actor_email = get_current_user(request, db_conn).get('email') - except Exception: - pass - write_audit_log(db_conn, eventType=LogEventType.ROLE_CHANGED, success=True, - userEmail=actor_email, + userEmail=requesting_user_email, device=request.headers.get("user-agent"), ipAddress=request.client.host, description=f"Role changed for {user_email} to {role.RoleName}.") @@ -237,7 +293,17 @@ async def update_user_role(user_email: str, role_id: int, request: Request, def _delete_user_data(user_email: str, db_conn: Session): """ - Deletes a user and all their associated data, returning a report of the deletion. + Delete a user and all their associated data, returning a report of the deletion. + + Removes patient access links, recommendations, predictions, health data, + validation tokens, and role mappings in the correct order to avoid + foreign-key violations. Rolls back on failure. + + :param user_email: The email of the account to delete. + :param db_conn: Database session. + :return: A dict with counts of deleted records per table. + :raises HTTPException 404: If the user is not found. + :raises HTTPException 500: If the deletion fails. """ deletion_report = {} @@ -305,7 +371,22 @@ def _delete_user_data(user_email: str, db_conn: Session): @router.delete("/users/{user_email}") async def delete_user_by_admin(user_email: str, request: Request, db_conn: Session = Depends(get_db)): - """Delete a user account """ + """ + Delete a user account and all associated data (admin only). + + Verifies the requesting user is an administrator, prevents self-deletion, + then cascades to remove health data, predictions, recommendations, and + role mappings. An audit log is written on both success and failure. + + :param user_email: The email of the user account to delete. + :param request: The HTTP request (for current-user extraction and audit logging). + :param db_conn: Database session provided by the FastAPI dependency. + :return: A dict with a success message and a ``deletion_report``. + :raises HTTPException 401: If the requesting user cannot be identified. + :raises HTTPException 403: If the requesting user is not an admin. + :raises HTTPException 404: If the target user is not found. + :raises HTTPException 400: If an admin attempts to delete their own account. + """ # Get the current user making the request current_user_data = get_current_user(request, db_conn) requesting_user_email = current_user_data.get('email') @@ -389,7 +470,20 @@ async def get_invalid_merchant_accounts(db_conn: Session = Depends(get_db)): @router.patch("/users/merchants/{merchant_email}") async def validate_merchant(merchant_email: str, request: Request, db_conn: Session = Depends(get_db)): - """Validate a merchant user account""" + """ + Validate a pending merchant account so they can access the platform. + + Verifies the requesting user is an administrator, then sets the + merchant's ``IsValidated`` flag to 1. An audit log is written on + both success and failure. + + :param merchant_email: The email of the merchant account to validate. + :param request: The HTTP request (for current-user extraction and audit logging). + :param db_conn: Database session provided by the FastAPI dependency. + :return: A dict with a success message. + :raises HTTPException 404: If the requesting user or merchant is not found. + :raises HTTPException 403: If the requesting user is not an admin. + """ # Check if requesting user is Admin. current_user = get_current_user(request, db_conn) current_user_email = current_user.get('email') @@ -511,6 +605,7 @@ async def get_logs( @router.get("/admin-dashboard/active-account-analytics") async def get_active_account_analytics(request: Request, db_conn: Session = Depends(get_db)): + """Return counts of active standard-user accounts in the past month and week.""" _confirm_admin(request, db_conn) prev_month = datetime.now() - timedelta(days=30) prev_week = datetime.now() - timedelta(days=7) @@ -545,6 +640,7 @@ async def get_active_account_analytics(request: Request, db_conn: Session = Depe @router.get("/admin-dashboard/active-merchant-analytics") async def get_active_merchant_analytics(request: Request, db_conn: Session = Depends(get_db)): + """Return counts of active merchant accounts in the past month and week.""" _confirm_admin(request, db_conn) prev_month = datetime.now() - timedelta(days=30) prev_week = datetime.now() - timedelta(days=7) @@ -579,6 +675,7 @@ async def get_active_merchant_analytics(request: Request, db_conn: Session = Dep @router.get("/admin-dashboard/recent-reports-generated-analytics") async def get_reports_generated_analytics(request: Request, db_conn: Session = Depends(get_db)): + """Return counts of health reports generated in the past month and week.""" _confirm_admin(request, db_conn) prev_month = datetime.now() - timedelta(days=30) prev_week = datetime.now() - timedelta(days=7) @@ -603,6 +700,7 @@ async def get_reports_generated_analytics(request: Request, db_conn: Session = D @router.get("/admin-dashboard/pending-merchants-analytics") async def get_pending_merchant_analytics(request: Request, db_conn: Session = Depends(get_db)): + """Return the number of merchant accounts awaiting validation.""" _confirm_admin(request, db_conn) pending_merchants = ( @@ -624,6 +722,7 @@ async def get_pending_merchant_analytics(request: Request, db_conn: Session = De @router.get("/admin-dashboard/predictions-distinct-years") async def get_predictions_distinct_years(request: Request, db_conn: Session = Depends(get_db)): + """Return a sorted list of distinct years in which predictions exist.""" _confirm_admin(request, db_conn) query = db_conn.query( extract("year", Prediction.CreatedAt).label("year") @@ -633,6 +732,15 @@ async def get_predictions_distinct_years(request: Request, db_conn: Session = De @router.get("/admin-dashboard/ave-risk-series/{year}") async def get_average_risk_series(year: int, request: Request, db_conn: Session = Depends(get_db)): + """ + Return monthly average risk probabilities (stroke, diabetes, CVD) for a given year. + + :param year: The calendar year to aggregate risk data for (e.g. 2025). + :param request: The HTTP request object (used for admin authentication). + :param db_conn: Database session provided by the FastAPI dependency. + :return: A list of dicts, each with ``date`` (YYYY-MM) and average ``stroke``, + ``diabetes``, ``cvd`` values. + """ _confirm_admin(request, db_conn) year_start = datetime(year, 1, 1) @@ -653,6 +761,7 @@ async def get_average_risk_series(year: int, request: Request, db_conn: Session @router.get("/admin-dashboard/login-activity/{timespanInDays}") async def get_login_activity(timespanInDays: int, request: Request, db_conn: Session = Depends(get_db)): + """Return daily login event counts over the specified number of days.""" _confirm_admin(request, db_conn) start_date = datetime.now() - timedelta(days=timespanInDays) @@ -670,6 +779,7 @@ async def get_login_activity(timespanInDays: int, request: Request, db_conn: Ses @router.get("/admin-dashboard/unvalidated-account-analytics") async def get_unvalidated_account_analytics(request: Request, db_conn: Session = Depends(get_db)): + """Return the number of standard-user accounts that have not been validated.""" _confirm_admin(request, db_conn) unvalidated_accounts = ( @@ -690,6 +800,7 @@ async def get_unvalidated_account_analytics(request: Request, db_conn: Session = @router.get("/admin-dashboard/user-analytics") async def get_user_analytics(request: Request, db_conn: Session = Depends(get_db)): + """Return aggregated counts of total, standard, patient-only, and merchant accounts.""" _confirm_admin(request, db_conn) account_total = db_conn.query(UserAccount).filter( @@ -716,6 +827,7 @@ async def get_user_analytics(request: Request, db_conn: Session = Depends(get_db def _confirm_admin(request: Request, db_conn: Session): + """Verify the requesting user has an admin role; raises 403/404 otherwise.""" user = get_current_user(request, db_conn) admin = db_conn.query(UserAccount).filter( UserAccount.Email == user["email"]).first() diff --git a/server/routers/authentication.py b/server/routers/authentication.py index d230c2d5..c6208535 100644 --- a/server/routers/authentication.py +++ b/server/routers/authentication.py @@ -107,7 +107,17 @@ class PasswordResetRequest(BaseModel): @router.post("/register") async def register(user_reg: UserRegistrationDetails, db_conn: Session = Depends(get_db)): - """Register a new account for the user provided the details are valid.""" + """ + Register a new user account after validating all input fields. + + Creates the user record, assigns a role, optionally creates a patient + profile (standard users), and generates an email-validation token. + + :param user_reg: Registration details including personal info, credentials, and account type. + :param db_conn: Database session provided by the FastAPI dependency. + :return: A dict with a success message. + :raises HTTPException 422: If any input field fails validation. + """ formatted_phone = format_phone_number(user_reg.phone) @@ -189,7 +199,7 @@ async def register(user_reg: UserRegistrationDetails, def _send_validation_email(user: UserAccount, token: str): - """Helper function to send a validation email.""" + """Send an email-validation link to the newly registered user.""" validation_url = f"http://localhost:8000/validate-email?token={token}" email_subject = "Validate your account" email_content = f""" @@ -211,11 +221,11 @@ def _send_validation_email(user: UserAccount, token: str): @router.get("/validate-email") async def validate_email_address(token: str, db_conn: Session = Depends(get_db)): + """Validate a user's email address via a signed token sent by email.""" validation_failure_exception = HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid or expired validation token." ) - """Validates a user's account""" # Find the token in the database validation_token_entry = db_conn.query( @@ -274,7 +284,19 @@ async def validate_email_address(token: str, db_conn: Session = Depends(get_db)) @router.post('/login') async def login(request: Request, response: Response, user_cred: LoginCredentials, db_conn: Session = Depends(get_db)): - """Authenticates a user with the credentials and provides an access token.""" + """ + Authenticate a user and issue an http-only cookie with a JWT access token. + + Validates credentials, increments the token version (invalidating previous + sessions), and sets the ``auth_token`` cookie on the response. + + :param request: The HTTP request (used for audit-logging client metadata). + :param response: The HTTP response (used to set the cookie). + :param user_cred: Login credentials (email and password). + :param db_conn: Database session provided by the FastAPI dependency. + :return: A dict with a success message. + :raises HTTPException 401: If credentials are incorrect. + """ credentials_exception = HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -312,6 +334,7 @@ async def login(request: Request, response: Response, user_cred: LoginCredential cookie_settings = _cookie_security_settings(request) + # bearer:disable python_django_cookies response.set_cookie( key='auth_token', value=token, @@ -331,7 +354,17 @@ async def login(request: Request, response: Response, user_cred: LoginCredential def authenticate_user(email: str, password: str, db_conn: Session): - """Authenticates a user from the provided email and password.""" + """ + Verify credentials and return the user account if authentication succeeds. + + Checks the user exists, is validated (if email validation is enabled), + and the password matches the stored hash. + + :param email: The user's email address. + :param password: The plain-text password to verify. + :param db_conn: Database session. + :return: The ``UserAccount`` object if authentication succeeds, ``False`` otherwise. + """ user = db_conn.query(UserAccount).filter_by(Email=email).first() if not user: return False @@ -350,7 +383,16 @@ def verify_password(password_text: str, password_hash: str) -> bool: def create_access_token(data: dict, expires_delta: timedelta | None = None): - """Returns JWT containing the access token with the given data.""" + """ + Create a signed JWT access token containing the given claims. + + Encodes the data dict with an ``exp`` claim set to the current time plus + the provided delta (or a default of 10 minutes). + + :param data: Claims to encode (must include ``sub`` for the subject). + :param expires_delta: Optional custom expiration duration. + :return: A signed JWT string. + """ to_encode = data.copy() if expires_delta: expire = datetime.now(timezone.utc) + expires_delta @@ -363,12 +405,24 @@ def create_access_token(data: dict, expires_delta: timedelta | None = None): @router.get('/user/me') async def get_user_me(request: Request, db_conn: Session = Depends(get_db)): - """Endpoint for retrieving the currently active user on a device.""" + """Return the currently authenticated user's profile information.""" return get_current_user(request, db_conn) def get_current_user(request: Request, db_conn: Session): - """Returns user information from the http-only cookie on their device.""" + """ + Extract and return the authenticated user's details from the http-only cookie. + + Decodes the JWT in the ``auth_token`` cookie, validates the token version + against the database, and fetches the user's role and patient profile. + + :param request: The HTTP request containing the ``auth_token`` cookie. + :param db_conn: Database session. + :return: A dict with keys ``email``, ``role``, ``name``, ``phone_number``, + ``given_names``, ``family_name``, ``gender``, ``weight``, ``height``, + ``date_of_birth``. + :raises HTTPException 401: If the token is missing, invalid, or expired. + """ # Prepare an exception for invalid or missing credentials. credentials_exception = HTTPException( @@ -451,7 +505,17 @@ def get_user_role(email: str, db_conn: Session): @router.post('/logout') def logout_current_user(request: Request, response: Response, db_conn: Session = Depends(get_db)): - """Deletes the user cookie and invalidates their access token.""" + """ + Log out the current user by deleting the auth cookie and invalidating the token. + + Increments the token version in the database so existing JWTs are rejected, + then removes the ``auth_token`` cookie from the response. + + :param request: The HTTP request (for current-user extraction). + :param response: The HTTP response (for cookie deletion). + :param db_conn: Database session. + :return: ``None`` (cookie is deleted on the response object). + """ try: user = get_current_user(request, db_conn) if user: @@ -477,7 +541,16 @@ def invalidate_access_token(email: str, db_conn: Session): def is_password_valid(password: str): - """Verifies the password follows policy rules.""" + """ + Verify a password meets all policy requirements. + + Rules: at least one lowercase, one uppercase, one digit, one symbol + (from ``VALID_PASSWORD_SYMBOLS``), and length between ``PASSWORD_MIN_LENGTH`` + and ``PASSWORD_MAX_LENGTH``. + + :param password: The plain-text password to check. + :return: ``True`` if the password complies with policy, ``False`` otherwise. + """ contains_lower = any(c.islower() for c in password) contains_upper = any(c.isupper() for c in password) @@ -494,7 +567,7 @@ def is_password_valid(password: str): def is_email_valid(email: str): - """Verifies a password follow the pattern xxx@xxx.xxx.""" + """Verifies an email follows the pattern xxx@xxx.xxx.""" if not email: return False try: @@ -555,7 +628,19 @@ def is_age_valid(date_of_birth: date): @router.post('/change-password') def change_password_current_user(password_details: ChangePasswordDetails, request: Request, db_conn: Session = Depends(get_db)): - """Change a user's password""" + """ + Change the authenticated user's password after verifying their current password. + + Validates the current password, confirms the new password matches the + confirmation field, then hashes and persists the new password. + + :param password_details: Object containing ``current_password``, ``new_password``, + and ``confirm_new_password``. + :param request: The HTTP request (for current-user extraction and audit logging). + :param db_conn: Database session. + :return: A dict with a success message. + :raises HTTPException 401: If current password is wrong or new passwords don't match. + """ # Retrieve current user data user_email = get_current_user(request, db_conn) @@ -590,7 +675,18 @@ def change_password_current_user(password_details: ChangePasswordDetails, reques @router.post('/forgot-password') def forgot_password(forgot_password_request: ForgotPasswordRequest, request: Request, db_conn: Session = Depends(get_db)): - """Generates a reset password token for a given email.""" + """ + Generate a password-reset token and email it to the user (if the account exists). + + Sanitises the email, looks up the user (excluding admin accounts), creates + a time-limited reset token, and sends a reset link. Responds identically + whether or not the email exists (to prevent enumeration). + + :param forgot_password_request: Object containing the user's email address. + :param request: The HTTP request (for audit-logging client metadata). + :param db_conn: Database session. + :return: ``None`` (email is sent asynchronously). + """ is_success = False sanitised_email = re.sub(r'[()<>[\]:,;\\]', '', @@ -642,7 +738,18 @@ async def password_reset( request: Request, db_conn: Session = Depends(get_db) ): - """Updates a user's password if the token is valid and password are valid.""" + """ + Reset a user's password using a valid, non-expired reset token. + + Validates the token, checks the new password against policy rules, + hashes it, and persists the change. The token is consumed (deleted) + regardless of success to prevent replay. + + :param reset_request: Object containing the reset token and new password. + :param request: The HTTP request (for audit-logging client metadata). + :param db_conn: Database session. + :return: ``None`` (audit log is written on every attempt). + """ is_successful = False user = None @@ -674,7 +781,17 @@ async def password_reset( def _send_reset_password_email(user: UserAccount, patient: Patient, request: Request, token: str): - """Helper function to send a validation email.""" + """ + Send a password-reset email containing a signed link to the user. + + Sanitises all dynamic content (token, name, IP, device) before embedding + in the HTML email to prevent XSS in rendered email clients. + + :param user: The target user account. + :param patient: The user's patient profile (for personalisation). + :param request: The HTTP request (for client IP and user-agent). + :param token: The unsigned reset token to embed in the link. + """ sanitizer = Sanitizer() sanitized_token = sanitizer.sanitize(token) given_names = sanitizer.sanitize(patient.GivenNames) diff --git a/server/routers/health_prediction.py b/server/routers/health_prediction.py index 59b6e829..28eb6f86 100644 --- a/server/routers/health_prediction.py +++ b/server/routers/health_prediction.py @@ -7,6 +7,7 @@ from sqlalchemy.orm import Session import csv import codecs +import logging from ..utils.database import get_db from ..models.dbmodels import HealthData, Prediction, Recommendation, UserAccount, UserPatientAccess, Patient, LogEventType @@ -14,9 +15,10 @@ from .authentication import get_current_user, get_user, get_patient_by_email from ..utils.audit_log import write_audit_log -# HealthData +logger = logging.getLogger(__name__) +# HealthData class HealthDataInput(CamelModel): age: int weight: float # kg @@ -29,11 +31,12 @@ class HealthDataInput(CamelModel): hypertension: int heart_disease: int diabetes: int - alcohol: int + alcohol: str smoker: str marital_status: str working_status: str stroke: int + race: str class MerchantHealthDataInput(CamelModel): @@ -48,11 +51,12 @@ class MerchantHealthDataInput(CamelModel): hypertension: int heart_disease: int diabetes: int - alcohol: int + alcohol: str smoker: str marital_status: str working_status: str stroke: int + race: str patient_id: int @@ -64,18 +68,28 @@ class MerchantHealthDataInput(CamelModel): gender_map = {'Male': 1, 'Female': 0} smoker_map = {'No': 0, 'Yes': 1, 'Former smoker': 2} -marital_map = {'Divorced': 0, 'Single': 0, 'Married': 1, 'Widow': 2} +marital_map = {'Single': 0, 'Married': 1, 'Widow': 2, 'Divorced': 3} working_map = { - 'Homemaker': 0, 'Unemployed': 0, 'Retired': 0, - 'Private': 1, 'Self-employed': 1, 'Student': 2, - 'Working': 3, 'Public': 4 + 'Unemployed': 0, 'Homemaker': 1, 'Student': 2, + 'Working': 3, 'Retired': 4 } - +race_map = {'Malay': 0, 'Chinese': 1, 'Indian': 2, 'Other': 3} +alcohol_map = {'Regular': 0, 'Occasional': 1, 'Non-drinker': 2} router = APIRouter() def build_model_input_df(model, values): + """ + Construct a single-row pandas DataFrame from model features and input values. + + Uses the model's ``feature_names_in_`` attribute as column headers when available, + falling back to a positional layout if the attribute is absent. + + :param model: A fitted scikit-learn model (expected to expose ``feature_names_in_``). + :param values: A list of numeric values corresponding to the model's feature set. + :return: A pandas DataFrame with one row suitable for ``model.predict_proba()``. + """ feature_names = getattr(model, "feature_names_in_", None) if feature_names is not None and len(feature_names) == len(values): return pd.DataFrame([values], columns=list(feature_names)) @@ -85,7 +99,21 @@ def build_model_input_df(model, values): @router.post("/health-prediction/") async def predict(data: HealthDataInput, request: Request, db_conn: Session = Depends(get_db), csv_patient_id: Optional[int] = None): - + """ + Generate cardio, stroke, and diabetes risk predictions for the authenticated user. + + Accepts health metrics via ``HealthDataInput``, sanitises and validates the data, + calculates BMI, runs three ML prediction models, persists health data and predictions + to the database, and generates AI-powered health recommendations (best-effort). + + :param data: Health metrics including vitals, lifestyle choices, and medical history. + :param request: The HTTP request object (used for audit logging and user extraction). + :param db_conn: Database session provided by the FastAPI dependency. + :param csv_patient_id: Optional patient ID forwarded from CSV bulk upload flow. + :return: JSON with ``cardioProbability``, ``strokeProbability``, ``diabetesProbability``, + and a ``recommendations`` block. + :raises HTTPException 422: If sanitised health data fails validation. + """ # Sanitize and normalize health data sanitized_data = sanitize_health_data(data) @@ -107,15 +135,31 @@ async def predict(data: HealthDataInput, request: Request, db_conn: Session = De # Update Weight & Height based on sanitized input. patient.Weight = sanitized_data["weight"] patient.Height = sanitized_data["height"] + patient.set_marital_status(sanitized_data["marital_status"]) + patient.set_working_status(sanitized_data["working_status"]) + patient.set_race(sanitized_data["race"]) # Get the CSV patients's ID, otherwise uses the authenticated user's ID. patient_id = csv_patient_id if csv_patient_id is not None else patient.PatientID - healthData = HealthData(patient_id, sanitized_data["age"], sanitized_data["weight"], sanitized_data["height"], gender_map[sanitized_data["gender"]], - sanitized_data["blood_glucose"], sanitized_data["ap_hi"], sanitized_data[ - "ap_lo"], sanitized_data["high_cholesterol"], - sanitized_data["hypertension"], sanitized_data[ - "heart_disease"], sanitized_data["diabetes"], sanitized_data["alcohol"], - smoker_map[sanitized_data["smoker"]], marital_map[sanitized_data["marital_status"]], working_map[sanitized_data["working_status"]], sanitized_data["stroke"]) + healthData = HealthData( + patient_id, + sanitized_data["age"], + sanitized_data["weight"], + sanitized_data["height"], + gender_map[sanitized_data["gender"]], + sanitized_data["blood_glucose"], + sanitized_data["ap_hi"], + sanitized_data["ap_lo"], + sanitized_data["high_cholesterol"], + sanitized_data["hypertension"], + sanitized_data["heart_disease"], + sanitized_data["diabetes"], + alcohol_map[sanitized_data["alcohol"]], + smoker_map[sanitized_data["smoker"]], + marital_map[sanitized_data["marital_status"]], + working_map[sanitized_data["working_status"]], + sanitized_data["stroke"], + race_map[sanitized_data["race"]]) # Store health data into the database db_conn.add(healthData) @@ -134,7 +178,7 @@ async def predict(data: HealthDataInput, request: Request, db_conn: Session = De sanitized_data["high_cholesterol"], sanitized_data["blood_glucose"], smoker_map[sanitized_data["smoker"]], - sanitized_data["alcohol"] + alcohol_map[sanitized_data["alcohol"]] ] cardio_df = build_model_input_df(cardio_model, cardio_values) # Cardio prediction @@ -192,8 +236,9 @@ async def predict(data: HealthDataInput, request: Request, db_conn: Session = De db_conn=db_conn, health_data_id=int(_hd_id)) else: recommendations = {"error": "Missing HealthDataID"} - except Exception as e: - recommendations = {"error": str(e)} + except Exception: + logger.error("Health recommendations failed to generate.") + recommendations = None # Persist recommendations when available exercise_rec = None @@ -296,7 +341,8 @@ async def upload_csv(request: Request, uploaded_file: UploadFile = File(...), heart_disease=int(row["HeartDisease"]) if row.get( "HeartDisease") else 0, diabetes=int(row["Diabetes"]) if row.get("Diabetes") else 0, - alcohol=int(row["Alcohol"]) if row.get("Alcohol") else 0, + alcohol=str(row["Alcohol"]) if row.get( + "Alcohol") else "", smoker=str(row["SmokingStatus"]) if row.get( "SmokingStatus") else "", marital_status=str(row["MaritalStatus"]) if row.get( @@ -304,6 +350,8 @@ async def upload_csv(request: Request, uploaded_file: UploadFile = File(...), working_status=str(row["WorkingStatus"]) if row.get( "WorkingStatus") else "", stroke=int(row["Stroke"]) if row.get("Stroke") else 0, + race=str(row["Race"]) if row.get( + "Race") else "", patient_id=patient.PatientID ) # Sanitize data before passing to merchant_predict @@ -377,6 +425,9 @@ async def merchant_predict(data: MerchantHealthDataInput, request: Request, db_c # Update Weight & Height based on sanitized input. patient.Weight = sanitized_data["weight"] patient.Height = sanitized_data["height"] + patient.set_marital_status(sanitized_data["marital_status"]) + patient.set_working_status(sanitized_data["working_status"]) + patient.set_race(sanitized_data["race"]) # Check if the merchant has permission to view the patients record if (merchant_view_patient(merchant.UserID, patient.PatientID, db_conn) == False): @@ -399,12 +450,24 @@ async def merchant_predict(data: MerchantHealthDataInput, request: Request, db_c BMI = (sanitized_data["weight"]/((sanitized_data["height"]/100)**2)) # Get the CSV user's ID, otherwise uses the authenticated user's ID. - healthData = HealthData(patient.PatientID, sanitized_data["age"], sanitized_data["weight"], sanitized_data["height"], gender_map[sanitized_data["gender"]], - sanitized_data["blood_glucose"], sanitized_data["ap_hi"], sanitized_data[ - "ap_lo"], sanitized_data["high_cholesterol"], - sanitized_data["hypertension"], sanitized_data[ - "heart_disease"], sanitized_data["diabetes"], sanitized_data["alcohol"], - smoker_map[sanitized_data["smoker"]], marital_map[sanitized_data["marital_status"]], working_map[sanitized_data["working_status"]], sanitized_data["stroke"]) + healthData = HealthData(patient.PatientID, + sanitized_data["age"], + sanitized_data["weight"], + sanitized_data["height"], + gender_map[sanitized_data["gender"]], + sanitized_data["blood_glucose"], + sanitized_data["ap_hi"], + sanitized_data["ap_lo"], + sanitized_data["high_cholesterol"], + sanitized_data["hypertension"], + sanitized_data["heart_disease"], + sanitized_data["diabetes"], + alcohol_map[sanitized_data["alcohol"]], + smoker_map[sanitized_data["smoker"]], + marital_map[sanitized_data["marital_status"]], + working_map[sanitized_data["working_status"]], + sanitized_data["stroke"], + race_map[sanitized_data["race"]]) # Store health data into the database db_conn.add(healthData) db_conn.commit() @@ -422,7 +485,7 @@ async def merchant_predict(data: MerchantHealthDataInput, request: Request, db_c sanitized_data["high_cholesterol"], sanitized_data["blood_glucose"], smoker_map[sanitized_data["smoker"]], - sanitized_data["alcohol"] + alcohol_map[sanitized_data["alcohol"]] ] cardio_df = build_model_input_df(cardio_model, cardio_values) # Cardio prediction @@ -480,8 +543,9 @@ async def merchant_predict(data: MerchantHealthDataInput, request: Request, db_c db_conn=db_conn, health_data_id=int(_hd_id)) else: recommendations = {"error": "Missing HealthDataID"} - except Exception as e: - recommendations = {"error": str(e)} + except Exception: + logger.error("Health recommendations failed to generate.") + recommendations = None # Persist recommendations when available exercise_rec = None @@ -524,76 +588,91 @@ async def merchant_predict(data: MerchantHealthDataInput, request: Request, db_c def is_age_valid(age: int): + """Check whether ``age`` falls within the valid physiological range (0–100).""" return age >= 0 and age <= 100 def is_weight_valid(weight: float): + """Check whether ``weight`` (kg) falls within the valid range (0.0–200.0).""" return weight >= 0.0 and weight <= 200.0 def is_height_valid(height: float): + """Check whether ``height`` (cm) falls within the valid range (0.0–300.0).""" return height >= 0.0 and height <= 300 def is_gender_valid(gender: int): + """Check whether ``gender`` is present in the predefined mapping.""" return gender in gender_map def is_blood_glucose_valid(blood_glucose: float): + """Check whether ``blood_glucose`` (mmol/L) falls within the valid range (0.0–20.0).""" return blood_glucose >= 0.0 and blood_glucose <= 20.0 def is_ap_hi_valid(ap_hi: float): + """Check whether systolic blood pressure ``ap_hi`` (mmHg) is valid (0.0–200.0).""" return ap_hi >= 0.0 and ap_hi <= 200.0 def is_ap_lo_valid(ap_lo: float): + """Check whether diastolic blood pressure ``ap_lo`` (mmHg) is valid (0.0–200.0).""" return ap_lo >= 0.0 and ap_lo <= 200.0 def is_high_cholesterol_valid(high_cholesterol: int): + """Check whether ``high_cholesterol`` is a binary flag (0 or 1).""" return high_cholesterol == 0 or high_cholesterol == 1 def is_hyper_tension_valid(hypertension: int): + """Check whether ``hypertension`` is a binary flag (0 or 1).""" return hypertension == 0 or hypertension == 1 def is_heart_disease_valid(heart_disease: int): + """Check whether ``heart_disease`` is a binary flag (0 or 1).""" return heart_disease == 0 or heart_disease == 1 def is_diabetes_valid(diabetes: int): + """Check whether ``diabetes`` is a binary flag (0 or 1).""" return diabetes == 0 or diabetes == 1 -def is_alcohol_valid(alcohol: int): - return alcohol == 0 or alcohol == 1 +def is_alcohol_valid(alcohol: str): + """Check whether ``alcohol`` is present in the predefined alcohol_map mapping.""" + return alcohol in alcohol_map def is_smoker_valid(smoker: str): + """Check whether ``smoker`` is present in the predefined smoker_map mapping.""" return smoker in smoker_map def is_marital_status_valid(marital_status: str): + """Check whether ``marital_status`` is present in the predefined mapping.""" return marital_status in marital_map def is_working_status_valid(working_status: str): + """Check whether ``working_status`` is present in the predefined mapping.""" return working_status in working_map def is_stroke_valid(stroke: int): + """Check whether ``stroke`` is a binary flag (0 or 1).""" return stroke == 0 or stroke == 1 +def is_race_valid(race: str): + return race in race_map + + def sanitize_health_data(data: HealthDataInput): - """ - Sanitise and normalize health data following phone sanitisation pattern. - Handles whitespace trimming, case normalization for categorical values, - and decimal precision for numeric values. - Returns normalized dict or None if any field fails critical validation. - """ + """Sanitise and normalise health data, returning a normalised dict or None if invalid.""" def normalize_categorical(value, mapping, field_name): """Helper: case-insensitive lookup with fallback to None.""" if not isinstance(value, str): @@ -619,6 +698,9 @@ def normalize_categorical(value, mapping, field_name): data.marital_status, marital_map, "marital_status") working_status = normalize_categorical( data.working_status, working_map, "working_status") + alcohol = normalize_categorical( + data.alcohol, alcohol_map, "alcohol") + race = normalize_categorical(data.race, race_map, "race") # Sanitize numeric fields (clamp to 2 decimal places) try: @@ -643,20 +725,18 @@ def normalize_categorical(value, mapping, field_name): "hypertension": data.hypertension, "heart_disease": data.heart_disease, "diabetes": data.diabetes, - "alcohol": data.alcohol, + "alcohol": alcohol, "smoker": smoker, "marital_status": marital_status, "working_status": working_status, "stroke": data.stroke, + "race": race, "patient_id": getattr(data, 'patient_id', None) } def validate_sanitized_data(sanitized_data: dict): - """ - Validates all sanitized/normalized data fields. - Returns True only if all fields are valid after sanitization. - """ + """Validate all sanitised data fields; return True only if all are valid.""" if sanitized_data is None: return False @@ -664,7 +744,7 @@ def validate_sanitized_data(sanitized_data: dict): not is_age_valid(sanitized_data.get("age")) or not is_weight_valid(sanitized_data.get("weight")) or not is_height_valid(sanitized_data.get("height")) or - sanitized_data.get("gender") is None or sanitized_data.get("gender") not in gender_map or + not is_gender_valid(sanitized_data.get("gender")) or not is_blood_glucose_valid(sanitized_data.get("blood_glucose")) or not is_ap_hi_valid(sanitized_data.get("ap_hi")) or not is_ap_lo_valid(sanitized_data.get("ap_lo")) or @@ -673,19 +753,19 @@ def validate_sanitized_data(sanitized_data: dict): not is_heart_disease_valid(sanitized_data.get("heart_disease")) or not is_diabetes_valid(sanitized_data.get("diabetes")) or not is_alcohol_valid(sanitized_data.get("alcohol")) or - sanitized_data.get("smoker") is None or sanitized_data.get("smoker") not in smoker_map or - sanitized_data.get("marital_status") is None or sanitized_data.get("marital_status") not in marital_map or - sanitized_data.get("working_status") is None or sanitized_data.get("working_status") not in working_map or - not is_stroke_valid(sanitized_data.get("stroke"))): + not is_smoker_valid(sanitized_data.get("smoker")) or + not is_marital_status_valid(sanitized_data.get("marital_status")) or + not is_working_status_valid(sanitized_data.get("working_status")) or + not is_stroke_valid(sanitized_data.get("stroke")) or + not is_race_valid(sanitized_data.get("race"))): + return False return True def validate_all_input(data: HealthDataInput): - """ - Validates all user inputs - """ + """Validate all user input fields; return True only if all are valid.""" if ( not is_age_valid(data.age) or not is_weight_valid(data.weight) or @@ -702,17 +782,15 @@ def validate_all_input(data: HealthDataInput): not is_smoker_valid(data.smoker) or not is_marital_status_valid(data.marital_status) or not is_working_status_valid(data.working_status) or - not is_stroke_valid(data.stroke)): - + not is_stroke_valid(data.stroke) or + not is_race_valid(data.race)): return False return True def merchant_view_patient(user_id: int, patient_id: int, db_conn: Session): - """ - Returns True if the merchant is linked to the patient, False if they can not. - """ + """Check whether a merchant is linked to a patient; return True if linked.""" access = db_conn.query(UserPatientAccess).filter_by( UserID=user_id, PatientID=patient_id diff --git a/server/routers/users.py b/server/routers/users.py index 040ea2ed..da9b8d6e 100644 --- a/server/routers/users.py +++ b/server/routers/users.py @@ -9,6 +9,7 @@ from sqlalchemy.orm import Session from html_sanitizer import Sanitizer import re +from sqlalchemy import func from ..utils.database import get_db from ..utils.audit_log import write_audit_log @@ -134,6 +135,7 @@ class PatientAcceptDetails(BaseModel): def _to_float(val) -> float: + """Safely convert a value to float, returning 0.0 on failure.""" if isinstance(val, Decimal): return float(val) try: @@ -146,6 +148,7 @@ def _to_float(val) -> float: def _validated_name(value: Optional[str], field_name: str) -> Optional[str]: + """Sanitise and validate a name field; raises 422 if longer than 255 chars.""" if value is None: return None sanitizer = Sanitizer() @@ -159,6 +162,7 @@ def _validated_name(value: Optional[str], field_name: str) -> Optional[str]: def _to_nullable_float(value: Optional[float], field_name: str, min_v: float, max_v: float) -> Optional[float]: + """Validate and return a nullable float within [min_v, max_v]; raises 422 if out of range.""" if value is None: return None if value < min_v or value > max_v: @@ -175,7 +179,20 @@ async def update_current_user_profile( request: Request, db_conn: Session = Depends(get_db), ): - """Updates a user's account information""" + """ + Update the authenticated user's account-level profile fields. + + Currently supports updating the ``phone_number`` field. The phone is + formatted and validated before persisting. + + :param payload: Object containing the fields to update (only ``phone_number`` supported). + :param request: The HTTP request (for audit-logging client metadata). + :param db_conn: Database session. + :return: A dict with a success message and the updated fields. + :raises HTTPException 404: If the user is not found. + :raises HTTPException 400: If no fields are provided for update. + :raises HTTPException 422: If the phone number is invalid. + """ current_user = get_current_user(request, db_conn) user = get_user(current_user["email"], db_conn) if user is None: @@ -219,7 +236,21 @@ async def update_current_patient_profile( request: Request, db_conn: Session = Depends(get_db), ): - """Update a user's patient information""" + """ + Update the authenticated user's patient profile fields. + + Supports updating ``given_names``, ``family_name``, ``gender``, ``weight``, + ``height``, and ``date_of_birth``. Creates a patient record if one does + not already exist. + + :param payload: Object containing the fields to update. + :param request: The HTTP request (for audit-logging client metadata). + :param db_conn: Database session. + :return: A dict with a success message and the updated fields. + :raises HTTPException 404: If the user is not found. + :raises HTTPException 400: If no fields are provided for update. + :raises HTTPException 422: If any field fails validation. + """ current_user = get_current_user(request, db_conn) user = get_user(current_user["email"], db_conn) if user is None: @@ -300,7 +331,17 @@ async def update_current_patient_profile( def _delete_user_data(user_id: int, db_conn: Session): """ - Deletes a user and all their associated data, returning a report of the deletion. + Delete a user and all associated data (cascading), returning a deletion report. + + Removes patient access links, recommendations, predictions, health data, + validation tokens, and role mappings in the correct order to avoid + foreign-key violations. Rolls back on failure. + + :param user_id: The ``UserID`` of the account to delete. + :param db_conn: Database session. + :return: A dict with counts of deleted records per table. + :raises HTTPException 404: If the user is not found. + :raises HTTPException 500: If the deletion fails. """ deletion_report = {} @@ -367,7 +408,18 @@ def _delete_user_data(user_id: int, db_conn: Session): @router.delete("/users/") async def delete_user(request: Request, db_conn: Session = Depends(get_db)): - """Delete a users account and their related data""" + """ + Delete the authenticated user's account and all associated data. + + Cascades to remove patient access links, health data, predictions, + recommendations, validation tokens, and role mappings. + + :param request: The HTTP request (for current-user extraction and audit logging). + :param db_conn: Database session. + :return: A dict with a success message. + :raises HTTPException 401: If credentials are invalid. + :raises HTTPException 404: If the user is not found. + """ # Current request user current = get_current_user(request, db_conn) request_user_email = current.get( @@ -402,16 +454,34 @@ async def delete_user(request: Request, db_conn: Session = Depends(get_db)): async def get_health_analytics( request: Request, db_conn: Session = Depends(get_db), + health_data_id: Optional[int] = None, ): - """ - Returns time-series health risk probabilities for the current user - using historical predictions stored in the database. - """ + """Return time-series health risk probabilities from the current user's historical predictions.""" user_email = get_current_user(request, db_conn) patient = get_patient_by_email(user_email["email"], db_conn) - if not patient: - return [] - patient_id = patient.PatientID + + if not health_data_id: + if not patient: + return [] + patient_id = patient.PatientID + + else: + # Retrieve patientID from selected health report. + health_data = db_conn.query(HealthData).filter(HealthData.HealthDataID == health_data_id).first() + if not health_data: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Health data not found.") + + patient_id = health_data.PatientID + + # Verify Merchant has access to patient. + merchant = db_conn.query(UserAccount).filter_by(Email=user_email["email"]).first() + merchant_access = (db_conn.query(UserPatientAccess) + .filter(UserPatientAccess.UserID == merchant.UserID, UserPatientAccess.PatientID == patient_id) + .first()) + + if not merchant_access: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Impermissible action.") + # Join predictions with health data to scope by user, order by prediction time rows = ( @@ -458,7 +528,15 @@ def month_label(dt: datetime) -> str: @router.get("/report-data/{healthDataId}") async def get_report_data(healthDataId: int, db_conn: Session = Depends(get_db)): - """Return data for a report""" + """ + Return a full health report (metrics, predictions, recommendations) by health-data ID. + + :param healthDataId: The primary key of the ``HealthData`` record. + :param db_conn: Database session. + :return: A ``Report`` object containing patient info, vitals, risk probabilities, + and AI recommendations. + :raises HTTPException 404: If the health data is not found. + """ validID = db_conn.query(HealthData).filter_by( HealthDataID=healthDataId).first() if not validID: @@ -525,7 +603,18 @@ async def get_report_data(healthDataId: int, db_conn: Session = Depends(get_db)) @router.delete("/report-data/{healthDataId}") async def delete_report_data(healthDataId: int, db_conn: Session = Depends(get_db)): - """Deletes data from a generated report""" + """ + Delete a health report and its associated predictions and recommendations. + + Removes the recommendation and prediction records first to avoid foreign-key + violations, then deletes the health-data row itself. + + :param healthDataId: The primary key of the ``HealthData`` record to delete. + :param db_conn: Database session. + :return: A dict with a success message. + :raises HTTPException 404: If the health data is not found. + :raises HTTPException 500: If the database deletion fails. + """ # Raise exception if health data is not in the DB health_data = db_conn.query(HealthData).filter_by( HealthDataID=healthDataId).first() @@ -643,7 +732,19 @@ async def get_clinic_names(request: Request, db_conn: Session = Depends(get_db)) @router.post("/create-patient/") async def create_patient(patient: PatientCreationDetails, request: Request, db_conn: Session = Depends(get_db),): - """Creates a new patient record and creates a relationship between the merchant and patient""" + """ + Create a new patient record and link it to the requesting merchant. + + Sanitises name inputs, checks for duplicate patients by name + DOB + gender, + then creates the patient and grants the merchant access. + + :param patient: Patient creation details (names, DOB, gender, weight, height). + :param request: The HTTP request (for current-merchant extraction). + :param db_conn: Database session. + :return: A dict with a success message and the new ``patient_id``. + :raises HTTPException 422: If any input field fails validation. + :raises HTTPException 409: If a matching patient already exists. + """ # Check if the requesting user is a merchant. merchant = get_current_merchant(request, db_conn) @@ -657,10 +758,30 @@ async def create_patient(patient: PatientCreationDetails, request: Request, db_c raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY) + # Sanitize name input and remove whitespace + sanitizer = Sanitizer() + sanitized_given_names = sanitizer.sanitize(patient.given_names).strip() + sanitized_family_name = sanitizer.sanitize(patient.family_name).strip() + + # Check if the patient already exists + existing_patient = db_conn.query(Patient).filter( + func.lower(Patient.GivenNames) == sanitized_given_names.lower(), + func.lower(Patient.FamilyName) == sanitized_family_name.lower(), + Patient.DateOfBirth == patient.date_of_birth, + Patient.Gender == gender_map[patient.gender] + ).first() + + if existing_patient: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Patient already exists." + ) + # Create new patient new_patient = Patient(user_id=None, - given_names=patient.given_names, - family_name=patient.family_name, gender=gender_map[patient.gender], + given_names=sanitized_given_names, + family_name=sanitized_family_name, + gender=gender_map[patient.gender], date_of_birth=patient.date_of_birth, weight=patient.weight, height=patient.height) @@ -746,7 +867,15 @@ async def associated_patients(request: Request, given_names: str = None, family_ def get_current_merchant(request: Request, db_conn): - """Check if the current user is a merchant""" + """ + Verify the requesting user is a merchant and return their ``UserAccount``. + + :param request: The HTTP request (for cookie-based user extraction). + :param db_conn: Database session. + :return: The ``UserAccount`` object of the merchant. + :raises HTTPException 404: If the user is not found. + :raises HTTPException 403: If the user's role is not ``merchant``. + """ # Check if the requesting user is a merchant. current_user = get_current_user(request, db_conn) @@ -776,7 +905,18 @@ def get_merchant_patients(merchantID: int, db_conn): @router.get("/dashboard", response_model=Dashboard) async def get_dashboard(request: Request, db_conn: Session = Depends(get_db)): - """Returns a user's dashboard information""" + """ + Return the authenticated user's dashboard with latest risks, trends, and recommendations. + + Fetches the last 5 predictions and calculates risk deltas, days since last + report, and the most recent AI-generated recommendations. + + :param request: The HTTP request (for current-user extraction). + :param db_conn: Database session. + :return: A ``Dashboard`` object containing ``days``, ``risks``, ``diff``, + and ``recommendations``. + :raises HTTPException 404: If the patient record is not found. + """ user = get_current_user(request, db_conn) patient = get_patient_by_email(user["email"], db_conn) @@ -861,6 +1001,17 @@ async def get_dashboard(request: Request, db_conn: Session = Depends(get_db)): @router.get("/merchant-dashboard", response_model=MerchantDashboard) async def get_merchant_dashboard(request: Request, db_conn: Session = Depends(get_db)): + """ + Return the merchant dashboard with patient/report stats and risk distributions. + + Aggregates data across all patients linked to the merchant: total and + inactive patient counts, report volumes, per-disease risk distribution + (high / moderate / low), and recent report activity. + + :param request: The HTTP request (for current-merchant extraction). + :param db_conn: Database session. + :return: A ``MerchantDashboard`` object with aggregated analytics. + """ merchant = get_current_merchant(request, db_conn) merchant_id = merchant.UserID @@ -975,9 +1126,7 @@ async def get_merchant_dashboard(request: Request, db_conn: Session = Depends(ge @router.get("/patient-data") async def get_patient_data(request: Request, db_conn: Session = Depends(get_db)): - """ - Retrieves a user's patient data required for report form inputs. - """ + """Return the authenticated user's patient data for report form inputs.""" # Get current user details. user = get_current_user(request, db_conn) @@ -991,7 +1140,10 @@ async def get_patient_data(request: Request, db_conn: Session = Depends(get_db)) "weight": float(patient.Weight) if patient.Weight else None, "height": float(patient.Height) if patient.Height else None, "gender": get_gender(patient.Gender), - "age": get_age(patient.DateOfBirth) + "age": get_age(patient.DateOfBirth), + "maritalStatus": patient.get_marital_status(), + "workingStatus": patient.get_working_status(), + "race": patient.get_race() } return result @@ -999,9 +1151,7 @@ async def get_patient_data(request: Request, db_conn: Session = Depends(get_db)) @router.get("/merchant/patient-data/{patient_id}") async def get_merchant_patient_data(patient_id: int, request: Request, db_conn: Session = Depends(get_db)): - """ - Allows merchant to retrieve a user's patient data required for report form inputs. - """ + """Return a patient's data for merchant report form inputs.""" # Check if the requesting user is a merchant. current_user = get_current_user(request, db_conn) @@ -1029,7 +1179,10 @@ async def get_merchant_patient_data(patient_id: int, request: Request, db_conn: "weight": float(patient.Weight) if patient.Weight else None, "height": float(patient.Height) if patient.Height else None, "gender": get_gender(patient.Gender), - "age": get_age(patient.DateOfBirth) + "age": get_age(patient.DateOfBirth), + "maritalStatus": patient.get_marital_status(), + "workingStatus": patient.get_working_status(), + "race": patient.get_race() } return result @@ -1137,7 +1290,21 @@ async def get_dashboard(patient_id: str, request: Request, db_conn: Session = De @router.post('/patient-request') def patient_request(patient_request: PatientRequest, request: Request, db_conn: Session = Depends(get_db)): - """Generates a patient request token for a given patient email.""" + """ + Generate a time-limited access token and email a patient access request. + + The merchant can request access to a patient's records via email. A + 7-day token is created and emailed to the patient for approval. + Only one active token is allowed per merchant-patient pair. + + :param patient_request: Object containing the patient's email address. + :param request: The HTTP request (for current-merchant extraction). + :param db_conn: Database session. + :return: A dict with a success message. + :raises HTTPException 422: If the email is invalid. + :raises HTTPException 404: If no patient is found for the email. + :raises HTTPException 409: If the merchant already has access to the patient. + """ # Check the current user is a merchant merchant = get_current_merchant(request, db_conn) @@ -1199,7 +1366,20 @@ def patient_request(patient_request: PatientRequest, request: Request, db_conn: @router.post('/patient-accept-request') def patient_accept_request(patient_accept_details: PatientAcceptDetails, request: Request, db_conn: Session = Depends(get_db)): - """Allows a patient to accept a request to be added to a merchant account""" + """ + Verify a patient-access token and grant the merchant access to the patient's records. + + The authenticated standard user must match the patient the request was + created for. On success, the token is consumed and a ``UserPatientAccess`` + row is created. + + :param patient_accept_details: Object containing the signed access token. + :param request: The HTTP request (for current-user extraction). + :param db_conn: Database session. + :return: A dict with a success message. + :raises HTTPException 403: If the user is not a standard user or doesn't match the patient. + :raises HTTPException 404: If the token is invalid, expired, or the patient is not found. + """ # Get current user's details current_user = get_current_user(request, db_conn) @@ -1244,7 +1424,18 @@ def patient_accept_request(patient_accept_details: PatientAcceptDetails, request def send_patient_request_email(email: str, patient: Patient, clinic: str, request: Request, token: str): - """Helper function to send a patient request email.""" + """ + Send an email to a patient requesting permission for a merchant to access their records. + + All dynamic content (token, name, clinic) is sanitised before embedding + in the HTML email body. + + :param email: The recipient patient's email address. + :param patient: The patient's profile (for personalisation). + :param clinic: The requesting clinic's name. + :param request: The HTTP request (for client-context logging; unused in body). + :param token: The unsigned access-request token to embed in the link. + """ sanitizer = Sanitizer() sanitized_token = sanitizer.sanitize(token) given_names = sanitizer.sanitize(patient.GivenNames) diff --git a/server/services/health_recommendation_service.py b/server/services/health_recommendation_service.py index c67afb1d..8c0b1041 100644 --- a/server/services/health_recommendation_service.py +++ b/server/services/health_recommendation_service.py @@ -172,7 +172,7 @@ def _fallback_recommendations(ctx: Dict): if "high cholesterol" in conditions: diet += "Increase soluble fiber (oats, legumes) and healthy fats (olive oil, nuts); reduce saturated fats. " - lifestyle = "[FALLBACK] Sleep 7–9 hours nightly, manage stress with short daily breathing or mindfulness. Hydrate adequately. " + lifestyle = "Sleep 7–9 hours nightly, manage stress with short daily breathing or mindfulness. Hydrate adequately. " if "smoking" in conditions: lifestyle += "Begin a smoking cessation plan (nicotine replacement or counseling). " if "alcohol use" in conditions: diff --git a/server/tests/routers/test_health_prediction.py b/server/tests/routers/test_health_prediction.py index dfaf97ab..001a6eeb 100644 --- a/server/tests/routers/test_health_prediction.py +++ b/server/tests/routers/test_health_prediction.py @@ -12,21 +12,21 @@ client = TestClient(app) # Test CSV data. -populated_rows = """GivenNames,LastName,Email,PhoneNumber,Age,WeightKilograms,HeightCentimetres,Gender,BloodGlucose,APHigh,APLow,HighCholesterol,HyperTension,HeartDisease,Diabetes,Alcohol,SmokingStatus,MaritalStatus,WorkingStatus,Stroke -User,1,user1@example.com,0412345678,31,50,170,Male,4.5,135,120,1,1,0,1,0,Yes,Single,Private,1 -User,2,user2@example.com,0812345678,31,60,170,Male,6,140,120,0,1,0,0,1,No,Married,Private,0""" +populated_rows = """GivenNames,LastName,Email,PhoneNumber,Age,WeightKilograms,HeightCentimetres,Gender,BloodGlucose,APHigh,APLow,HighCholesterol,HyperTension,HeartDisease,Diabetes,Alcohol,SmokingStatus,MaritalStatus,WorkingStatus,Stroke,Race +User,1,user1@example.com,0412345678,31,50,170,Male,4.5,135,120,1,1,0,1,Regular,Yes,Single,Working,1,Malay +User,2,user2@example.com,0812345678,31,60,170,Male,6,140,120,0,1,0,0,Regular,No,Married,Working,0,Chinese""" -unpopulated_rows = """GivenNames,LastName,Email,PhoneNumber,Age,WeightKilograms,HeightCentimetres,Gender,BloodGlucose,APHigh,APLow,HighCholesterol,HyperTension,HeartDisease,Diabetes,Alcohol,SmokingStatus,MaritalStatus,WorkingStatus,Stroke +unpopulated_rows = """GivenNames,LastName,Email,PhoneNumber,Age,WeightKilograms,HeightCentimetres,Gender,BloodGlucose,APHigh,APLow,HighCholesterol,HyperTension,HeartDisease,Diabetes,Alcohol,SmokingStatus,MaritalStatus,WorkingStatus,Stroke,Race ,,,,,,,,,,,,,,,,,,, ,,,,,,,,,,,,,,,,,,,""" -one_populated_row = """GivenNames,LastName,Email,PhoneNumber,Age,WeightKilograms,HeightCentimetres,Gender,BloodGlucose,APHigh,APLow,HighCholesterol,HyperTension,HeartDisease,Diabetes,Alcohol,SmokingStatus,MaritalStatus,WorkingStatus,Stroke +one_populated_row = """GivenNames,LastName,Email,PhoneNumber,Age,WeightKilograms,HeightCentimetres,Gender,BloodGlucose,APHigh,APLow,HighCholesterol,HyperTension,HeartDisease,Diabetes,Alcohol,SmokingStatus,MaritalStatus,WorkingStatus,Stroke,Race ,,,,,,,,,,,,,,,,,,, -User,2,user2@example.com,0812345678,31,60,170,Male,6,140,120,0,1,0,0,1,No,Married,Private,0""" +User,2,user2@example.com,0812345678,31,60,170,Male,6,140,120,0,1,0,0,Regular,No,Married,Working,0,Malay""" -missing_email = """GivenNames,LastName,Email,PhoneNumber,Age,WeightKilograms,HeightCentimetres,Gender,BloodGlucose,APHigh,APLow,HighCholesterol,HyperTension,HeartDisease,Diabetes,Alcohol,SmokingStatus,MaritalStatus,WorkingStatus,Stroke -User,1,user1@example.com,0412345678,31,50,170,Male,4.5,135,120,1,1,0,1,0,Yes,Single,Private,1 -User,2,,0812345678,31,60,170,Male,6,140,120,0,1,0,0,1,No,Married,Private,0""" +missing_email = """GivenNames,LastName,Email,PhoneNumber,Age,WeightKilograms,HeightCentimetres,Gender,BloodGlucose,APHigh,APLow,HighCholesterol,HyperTension,HeartDisease,Diabetes,Alcohol,SmokingStatus,MaritalStatus,WorkingStatus,Stroke,Race +User,1,user1@example.com,0412345678,31,50,170,Male,4.5,135,120,1,1,0,1,Regular,Yes,Single,Working,1,Malay +User,2,,0812345678,31,60,170,Male,6,140,120,0,1,0,0,Regular,No,Married,Working,0,Malay""" @pytest.fixture(scope="session", autouse=True) @@ -257,8 +257,8 @@ def test_sanitize_whitespace_in_gender(self): age=30, weight=75.0, height=170.0, gender=" Male ", blood_glucose=5.5, ap_hi=120, ap_lo=80, high_cholesterol=0, hypertension=0, heart_disease=0, - diabetes=0, alcohol=0, - smoker="Yes", marital_status="Married", working_status="Private", stroke=0 + diabetes=0, alcohol="Regular", + smoker="Yes", marital_status="Married", working_status="Working", stroke=0, race="Malay" ) result = sanitize_health_data(data) assert result is not None @@ -270,8 +270,8 @@ def test_sanitize_case_insensitive_gender(self): age=30, weight=75.0, height=170.0, gender="female", blood_glucose=5.5, ap_hi=120, ap_lo=80, high_cholesterol=0, hypertension=0, heart_disease=0, - diabetes=0, alcohol=0, - smoker="Yes", marital_status="Married", working_status="Private", stroke=0 + diabetes=0, alcohol="Regular", + smoker="Yes", marital_status="Married", working_status="Working", stroke=0, race="Malay" ) result = sanitize_health_data(data) assert result is not None @@ -283,8 +283,8 @@ def test_sanitize_case_insensitive_smoker(self): age=30, weight=75.0, height=170.0, gender="Male", blood_glucose=5.5, ap_hi=120, ap_lo=80, high_cholesterol=0, hypertension=0, heart_disease=0, - diabetes=0, alcohol=0, - smoker="NO", marital_status="Married", working_status="Private", stroke=0 + diabetes=0, alcohol="Regular", + smoker="NO", marital_status="Married", working_status="Working", stroke=0, race="Malay" ) result = sanitize_health_data(data) assert result is not None @@ -296,8 +296,8 @@ def test_sanitize_former_smoker_with_spaces(self): age=30, weight=75.0, height=170.0, gender="Male", blood_glucose=5.5, ap_hi=120, ap_lo=80, high_cholesterol=0, hypertension=0, heart_disease=0, - diabetes=0, alcohol=0, - smoker=" FORMER SMOKER ", marital_status="Married", working_status="Private", stroke=0 + diabetes=0, alcohol="Regular", + smoker=" FORMER SMOKER ", marital_status="Married", working_status="Working", stroke=0, race="Malay" ) result = sanitize_health_data(data) assert result is not None @@ -309,8 +309,8 @@ def test_sanitize_decimal_precision_clamping(self): age=30, weight=75.12345, height=170.6789, gender="Male", blood_glucose=5.6789, ap_hi=120.123, ap_lo=80.999, high_cholesterol=0, hypertension=0, heart_disease=0, - diabetes=0, alcohol=0, - smoker="Yes", marital_status="Married", working_status="Private", stroke=0 + diabetes=0, alcohol="Regular", + smoker="Yes", marital_status="Married", working_status="Working", stroke=0, race="Malay" ) result = sanitize_health_data(data) assert result is not None @@ -326,8 +326,8 @@ def test_sanitize_invalid_gender_returns_none(self): age=30, weight=75.0, height=170.0, gender="InvalidGender", blood_glucose=5.5, ap_hi=120, ap_lo=80, high_cholesterol=0, hypertension=0, heart_disease=0, - diabetes=0, alcohol=0, - smoker="Yes", marital_status="Married", working_status="Private", stroke=0 + diabetes=0, alcohol="Regular", + smoker="Yes", marital_status="Married", working_status="Working", stroke=0, race="Malay" ) result = sanitize_health_data(data) assert result is not None @@ -339,8 +339,8 @@ def test_sanitize_case_insensitive_marital_status(self): age=30, weight=75.0, height=170.0, gender="Male", blood_glucose=5.5, ap_hi=120, ap_lo=80, high_cholesterol=0, hypertension=0, heart_disease=0, - diabetes=0, alcohol=0, - smoker="Yes", marital_status=" MARRIED ", working_status="Private", stroke=0 + diabetes=0, alcohol="Regular", + smoker="Yes", marital_status=" MARRIED ", working_status="Working", stroke=0, race="Malay" ) result = sanitize_health_data(data) assert result is not None @@ -364,11 +364,12 @@ def test_validate_valid_sanitized_data(self): "hypertension": 0, "heart_disease": 0, "diabetes": 0, - "alcohol": 0, + "alcohol": "Regular", "smoker": "Yes", "marital_status": "Married", - "working_status": "Private", + "working_status": "Working", "stroke": 0, + "race": "Malay", "patient_id": None } assert validate_sanitized_data(sanitized) is True @@ -387,11 +388,12 @@ def test_validate_rejects_none_gender(self): "hypertension": 0, "heart_disease": 0, "diabetes": 0, - "alcohol": 0, + "alcohol": "Regular", "smoker": "Yes", "marital_status": "Married", - "working_status": "Private", + "working_status": "Working", "stroke": 0, + "race": "Malay", "patient_id": None } assert validate_sanitized_data(sanitized) is False @@ -410,11 +412,12 @@ def test_validate_rejects_none_smoker(self): "hypertension": 0, "heart_disease": 0, "diabetes": 0, - "alcohol": 0, + "alcohol": "Regular", "smoker": None, # Invalid "marital_status": "Married", - "working_status": "Private", + "working_status": "Working", "stroke": 0, + "race": "Malay", "patient_id": None } assert validate_sanitized_data(sanitized) is False @@ -433,11 +436,12 @@ def test_validate_rejects_out_of_range_age(self): "hypertension": 0, "heart_disease": 0, "diabetes": 0, - "alcohol": 0, + "alcohol": "Regular", "smoker": "Yes", "marital_status": "Married", - "working_status": "Private", + "working_status": "Working", "stroke": 0, + "race": "Malay", "patient_id": None } assert validate_sanitized_data(sanitized) is False @@ -468,11 +472,12 @@ def test_healthprediction_with_lowercase_gender(self): "hypertension": 0, "heart_disease": 0, "diabetes": 0, - "alcohol": 0, + "alcohol": "Regular", "smoker": "Yes", "marital_status": "Married", - "working_status": "Private", - "stroke": 0 + "working_status": "Working", + "stroke": 0, + "race": "Malay", } response = client.post("/health-prediction/", json=health_data) @@ -500,11 +505,12 @@ def test_healthprediction_with_whitespace_smoker(self): "hypertension": 0, "heart_disease": 0, "diabetes": 0, - "alcohol": 0, + "alcohol": "Regular", "smoker": " No ", # whitespace "marital_status": "Married", - "working_status": "Private", - "stroke": 0 + "working_status": "Working", + "stroke": 0, + "race": "Malay" } response = client.post("/health-prediction/", json=health_data) @@ -527,8 +533,8 @@ def ensure_merchant_login(self): def test_csv_upload_with_case_variations(self): """CSV upload handles case variations in categorical fields.""" - csv_data = """GivenNames,LastName,Email,PhoneNumber,Age,WeightKilograms,HeightCentimetres,Gender,BloodGlucose,APHigh,APLow,HighCholesterol,HyperTension,HeartDisease,Diabetes,Alcohol,SmokingStatus,MaritalStatus,WorkingStatus,Stroke -User,3,user1@example.com,0412345678,31,50,170,male,4.5,135,120,1,1,0,1,0,no,single,private,1""" + csv_data = """GivenNames,LastName,Email,PhoneNumber,Age,WeightKilograms,HeightCentimetres,Gender,BloodGlucose,APHigh,APLow,HighCholesterol,HyperTension,HeartDisease,Diabetes,Alcohol,SmokingStatus,MaritalStatus,WorkingStatus,Stroke,Race +User,3,user1@example.com,0412345678,31,50,170,male,4.5,135,120,1,1,0,1,Regular,no,single,working,1,Malay""" pre_count = count_health_data() response = upload_csv(csv_data) @@ -541,8 +547,8 @@ def test_csv_upload_with_case_variations(self): def test_csv_upload_with_invalid_gender_skipped(self): """CSV upload skips rows with invalid gender after attempted sanitization.""" - csv_data = """GivenNames,LastName,Email,PhoneNumber,Age,WeightKilograms,HeightCentimetres,Gender,BloodGlucose,APHigh,APLow,HighCholesterol,HyperTension,HeartDisease,Diabetes,Alcohol,SmokingStatus,MaritalStatus,WorkingStatus,Stroke -User,4,user1@example.com,0412345678,31,50,170,InvalidGender,4.5,135,120,1,1,0,1,0,Yes,Single,Private,1""" + csv_data = """GivenNames,LastName,Email,PhoneNumber,Age,WeightKilograms,HeightCentimetres,Gender,BloodGlucose,APHigh,APLow,HighCholesterol,HyperTension,HeartDisease,Diabetes,Alcohol,SmokingStatus,MaritalStatus,WorkingStatus,Stroke,Race +User,4,user1@example.com,0412345678,31,50,170,InvalidGender,4.5,135,120,1,1,0,1,Regular,Yes,Single,working,1,Malay""" pre_count = count_health_data() response = upload_csv(csv_data) diff --git a/server/tests/routers/test_users.py b/server/tests/routers/test_users.py index 355b68da..92f2e018 100644 --- a/server/tests/routers/test_users.py +++ b/server/tests/routers/test_users.py @@ -126,6 +126,19 @@ def setup_once_for_all_tests(): db_conn.commit() +# Removes patient record after a test +@pytest.fixture +def cleanup_patient(): + db_conn = next(get_db()) + created_ids = [] + yield created_ids + for patient_id in created_ids: + db_conn.query(UserPatientAccess).filter( + UserPatientAccess.PatientID == patient_id).delete() + db_conn.query(Patient).filter(Patient.PatientID == patient_id).delete() + db_conn.commit() + + def test_delete_requires_authentication(): fresh = TestClient(app) res = fresh.delete("/users/") @@ -189,7 +202,15 @@ def test_get_patient_data_returns_correct_fields(): data = response.json() assert response.status_code == status.HTTP_200_OK - assert data.keys() == {"weight", "height", "gender", "age"} + assert data.keys() == { + "workingStatus", + "maritalStatus", + "weight", + "height", + "gender", + "age", + "race" + } assert data["gender"] == "Male" assert data["age"] == datetime.today().year - 1980 - \ ((datetime.today().month, datetime.today().day) < (5, 24)) @@ -227,7 +248,15 @@ def test_merchant_get_patient_data_returns_correct_fields(): data = response.json() assert response.status_code == status.HTTP_200_OK - assert data.keys() == {"weight", "height", "gender", "age"} + assert data.keys() == { + "workingStatus", + "maritalStatus", + "weight", + "height", + "gender", + "age", + "race" + } assert data["gender"] == "Male" assert data["age"] == datetime.today().year - 1980 - \ ((datetime.today().month, datetime.today().day) < (5, 24)) @@ -244,7 +273,7 @@ def test_merchant_cannot_access_other_patients(): assert response.status_code == status.HTTP_404_NOT_FOUND -def test_create_patient(): +def test_create_patient(cleanup_patient): # Login as Merchant credentials = {"email": "service@example.com", "password": "thisismypassword"} @@ -265,6 +294,7 @@ def test_create_patient(): assert response.status_code == status.HTTP_200_OK assert response.json()["message"] == 'Patient successfully created.' assert isinstance(response.json()["patient_id"], int) + cleanup_patient.append(response.json()["patient_id"]) def test_create_patient_non_merchant(): @@ -359,7 +389,7 @@ def test_create_patient_invalid_height(): assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY -def test_remove_patient(): +def test_remove_patient(cleanup_patient): # Login as Merchant credentials = {"email": "service@example.com", "password": "thisismypassword"} @@ -368,7 +398,7 @@ def test_remove_patient(): assert login_res.json() == {'message': f'Successfully logged in.'} # Patient Details patient = { - "given_names": "Timmy", + "given_names": "John", "family_name": "Smith", "date_of_birth": "1988-04-04", "gender": "Male", @@ -380,6 +410,7 @@ def test_remove_patient(): assert response.status_code == status.HTTP_200_OK assert response.json()["message"] == "Patient successfully created." assert isinstance(response.json()["patient_id"], int) + cleanup_patient.append(response.json()["patient_id"]) response = client.delete( f"/remove-patient/{response.json()["patient_id"]}") @@ -387,6 +418,68 @@ def test_remove_patient(): assert response.json() == {'message': f'Patient successfully removed.'} +def test_create_duplicate_patient(cleanup_patient): + # Login as Merchant + credentials = {"email": "service@example.com", + "password": "thisismypassword"} + login_res = client.post("/login/", json=credentials) + assert login_res.status_code == status.HTTP_200_OK + assert login_res.json() == {'message': f'Successfully logged in.'} + # Patient Details + patient = { + "given_names": "Timmy", + "family_name": "Smith", + "date_of_birth": "1988-04-04", + "gender": "Male", + "weight": 80, + "height": 180 + } + # Create Patient + response = client.post("/create-patient", json=patient) + assert response.status_code == status.HTTP_200_OK + assert isinstance(response.json()["patient_id"], int) + cleanup_patient.append(response.json()["patient_id"]) + + # Attempt to create the patient record again + response = client.post("/create-patient", json=patient) + assert response.status_code == status.HTTP_409_CONFLICT + + +def test_create_duplicate_patient_whitespace(cleanup_patient): + # Login as Merchant + credentials = {"email": "service@example.com", + "password": "thisismypassword"} + login_res = client.post("/login/", json=credentials) + assert login_res.status_code == status.HTTP_200_OK + assert login_res.json() == {'message': f'Successfully logged in.'} + # Patient Details + patient = { + "given_names": "Timmy", + "family_name": "Smith", + "date_of_birth": "1988-04-04", + "gender": "Male", + "weight": 80, + "height": 180 + } + whitespace_patient = { + "given_names": " Timmy", + "family_name": " Smith", + "date_of_birth": "1988-04-04", + "gender": "Male", + "weight": 80, + "height": 180 + } + # Create Patient + response = client.post("/create-patient", json=patient) + assert response.status_code == status.HTTP_200_OK + assert isinstance(response.json()["patient_id"], int) + cleanup_patient.append(response.json()["patient_id"]) + + # Attempt to create the patient record again using the same patient names with whitespace + response = client.post("/create-patient", json=whitespace_patient) + assert response.status_code == status.HTTP_409_CONFLICT + + class TestPatientAccessRequestFlow(): def test_successful_request(self): diff --git a/server/tools/generate_dummy_data.py b/server/tools/generate_dummy_data.py index d7c6c11b..4384d30e 100644 --- a/server/tools/generate_dummy_data.py +++ b/server/tools/generate_dummy_data.py @@ -76,6 +76,7 @@ class UserRoleID(Enum): def load_names_csv(filename: str): '''Loads all the names in the CSV file to use in the dummy data''' + # bearer:disable python_lang_path_traversal with open(filename, encoding="utf-8") as csv_file: csv_reader = csvReader(csv_file, delimiter=',') for row in csv_reader: diff --git a/server/utils/audit_log.py b/server/utils/audit_log.py index 172b7e44..a71e385f 100644 --- a/server/utils/audit_log.py +++ b/server/utils/audit_log.py @@ -2,11 +2,22 @@ from ..models.dbmodels import AuditLog, LogEventType -# Utility function for writing audit logs to the database. -# Call this from any router when a loggable event occurs. - def write_audit_log(db_conn: Session, eventType: LogEventType, success: bool, userID: int = None, userEmail: str = None, ipAddress: str = None, device: str = None, description: str = None): + """Persist an audit-log entry to the database and commit immediately. + + Call this from any router when a loggable event occurs. Failures are + silently caught and printed to stderr so they never break the request flow. + + :param db_conn: Active database session. + :param eventType: The category of event being logged. + :param success: Whether the operation succeeded. + :param userID: Optional primary key of the acting user. + :param userEmail: Optional email of the acting user. + :param ipAddress: Optional client IP address. + :param device: Optional device / user-agent string. + :param description: Optional human-readable description of the event. + """ try: db_conn.add(AuditLog( eventType=eventType, diff --git a/server/utils/encryptor.py b/server/utils/encryptor.py index 145204db..a5675d66 100644 --- a/server/utils/encryptor.py +++ b/server/utils/encryptor.py @@ -9,6 +9,13 @@ def load_key(): + """Load the AES-256 key from the ``DATA_CIPHER_KEY`` environment variable. + + Supports both raw Base64-encoded (~44 chars) and direct 32-byte UTF-8 keys. + + :return: A 32-byte key suitable for AES-256-GCM. + :raises RuntimeError: If the env var is missing, unparseable, or not 32 bytes. + """ raw = os.getenv('DATA_CIPHER_KEY') if not raw: raise RuntimeError('Missing env DATA_CIPHER_KEY') @@ -26,6 +33,15 @@ def load_key(): def encrypt(plain_text): + """Encrypt plain-text with AES-256-GCM and return a versioned string. + + Output format: ``v1:gcm:base64(iv):base64(tag):base64(cipher)``. + Accepts strings (encoded as UTF-8) or raw bytes. ``None`` is treated + as an empty string. + + :param plain_text: The data to encrypt (``str`` or ``bytes``). + :return: A colon-delimited versioned cipher-text string. + """ if plain_text is None: plain_text = '' if isinstance(plain_text, str): @@ -46,6 +62,15 @@ def encrypt(plain_text): def decrypt(payload): + """Decrypt a versioned cipher-string produced by :func:`encrypt`. + + Expects the ``v1:gcm:base64(iv):base64(tag):base64(cipher)`` format. + Validates IV length (12 bytes) and cipher format before decryption. + + :param payload: The versioned cipher-string to decrypt. + :return: The original UTF-8 plain-text. + :raises RuntimeError: If the format, IV length, or authentication tag is invalid. + """ if not isinstance(payload, str): raise RuntimeError('Cipher payload must be string') parts = payload.split(':')