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 - 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 (
-
- );
-};
-
-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 (
-
- );
-};
-
-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 },
}}
>
-
+
{clinicData.map((clinic) => (
);
};
diff --git a/client/src/components/authentication/RegistrationForm.js b/client/src/components/authentication/RegistrationForm.js
index 08d71cb2..d9a5939e 100644
--- a/client/src/components/authentication/RegistrationForm.js
+++ b/client/src/components/authentication/RegistrationForm.js
@@ -1,5 +1,5 @@
-import { useState, useEffect } from "react";
-import { useNavigate, Link as RouterLink } from "react-router-dom";
+import AccountBalanceIcon from "@mui/icons-material/AccountBalance";
+import AccountCircleIcon from "@mui/icons-material/AccountCircle";
import {
Alert,
Box,
@@ -7,9 +7,9 @@ import {
Card,
CardContent,
Dialog,
+ DialogActions,
DialogContent,
DialogTitle,
- DialogActions,
Divider,
FormControl,
FormHelperText,
@@ -20,12 +20,13 @@ import {
TextField,
Typography,
} from "@mui/material";
-import AccountCircleIcon from "@mui/icons-material/AccountCircle";
-import AccountBalanceIcon from "@mui/icons-material/AccountBalance";
-import PasswordInputField from "../authentication/PasswordInputField";
+import { useEffect, useState } from "react";
+import { Link as RouterLink, useNavigate } from "react-router-dom";
+import Logo from "../../assets/WellAiLogoTR.png";
import EmailInputField from "../authentication/EmailInputField";
+import PasswordInputField from "../authentication/PasswordInputField";
import PhoneInputField from "../authentication/PhoneInputField";
-import Logo from "../../assets/WellAiLogoTR.png";
+import { stringEqual } from "../../utils/stringEqual";
const FULL_NAME_MAX_LENGTH = 255;
const ACCOUNT_TYPES = Object.freeze({
@@ -35,6 +36,12 @@ const ACCOUNT_TYPES = Object.freeze({
const API_BASE = process.env.REACT_APP_API_URL || "http://localhost:8000";
+/**
+ * A form that can be filled out by a user to create an account with the
+ * service.
+ *
+ * @returns {@mui.material.Card}
+ */
const RegistrationForm = () => {
const navigate = useNavigate();
const [givenNameState, setGivenNameState] = useState(null);
@@ -71,7 +78,7 @@ const RegistrationForm = () => {
})
.then((res) => res.json())
.then((data) => setClinicList(data))
- .catch((err) => console.log("An error has occurred"));
+ .catch((err) => console.error("An error has occurred."));
}, []);
function updateGivenName(e) {
@@ -128,7 +135,7 @@ const RegistrationForm = () => {
const confirmPasswordInput = e.target.value;
setConfirmPassword(confirmPasswordInput);
setAlertPasswordsDontMatch(
- confirmPasswordInput !== passwordState.password ||
+ !stringEqual(confirmPasswordInput, passwordState.password) ||
confirmPasswordInput === "",
);
}
@@ -176,7 +183,7 @@ const RegistrationForm = () => {
setAlertPasswordRequired(passwordState === null);
setAlertPasswordsDontMatch(
passwordState === null ||
- confirmPassword !== passwordState.password ||
+ !stringEqual(confirmPassword, passwordState.password) ||
confirmPassword === "",
);
setAlertGenderRequired(genderState === "");
@@ -188,7 +195,7 @@ const RegistrationForm = () => {
setAlertPasswordRequired(passwordState === null);
setAlertPasswordsDontMatch(
passwordState === null ||
- confirmPassword !== passwordState.password ||
+ !stringEqual(confirmPassword, passwordState.password) ||
confirmPassword === "",
);
setAlertClinicRequired(clinicState === "");
@@ -208,7 +215,7 @@ const RegistrationForm = () => {
(phoneState === null || phoneState.isValid) &&
passwordState !== null &&
passwordState.isValid &&
- passwordState.password === confirmPassword
+ stringEqual(passwordState.password, confirmPassword)
);
}
@@ -219,7 +226,7 @@ const RegistrationForm = () => {
(phoneState === null || phoneState.isValid) &&
passwordState !== null &&
passwordState.isValid &&
- passwordState.password === confirmPassword &&
+ stringEqual(passwordState.password, confirmPassword) &&
clinicState !== ""
);
}
@@ -275,7 +282,7 @@ const RegistrationForm = () => {
setShowFailMessage(false);
})
.catch((error) => {
- console.log(error);
+ console.error("User reigstration requeset failed.");
});
setIsLoading(false);
}
diff --git a/client/src/components/authentication/ResetPasswordForm.js b/client/src/components/authentication/ResetPasswordForm.js
index 6f78200a..51f19108 100644
--- a/client/src/components/authentication/ResetPasswordForm.js
+++ b/client/src/components/authentication/ResetPasswordForm.js
@@ -1,4 +1,5 @@
-import { useState } from "react";
+import CheckIcon from "@mui/icons-material/Check";
+import LockResetIcon from "@mui/icons-material/LockReset";
import {
Box,
Button,
@@ -9,16 +10,17 @@ import {
TextField,
Typography,
} from "@mui/material";
-import { useParams, Link as RouterLink } from "react-router-dom";
-import CheckIcon from "@mui/icons-material/Check";
-import LockResetIcon from "@mui/icons-material/LockReset";
+import { useState } from "react";
+import { Link as RouterLink, useParams } from "react-router-dom";
import Logo from "../../assets/WellAiLogoTR.png";
import PasswordInputField from "../authentication/PasswordInputField";
const API_BASE = process.env.REACT_APP_API_URL || "http://localhost:8000";
/**
- * Provides a form to reset their password one time with a reset token.
+ * A form to reset a user's password using a one-time reset token.
+ *
+ * @returns {@mui.material.Card}
*/
const ResetPasswordForm = () => {
const { token } = useParams();
diff --git a/client/src/components/dialog/DisclaimerPolicy.js b/client/src/components/dialog/DisclaimerPolicy.js
new file mode 100644
index 00000000..7cd331fa
--- /dev/null
+++ b/client/src/components/dialog/DisclaimerPolicy.js
@@ -0,0 +1,96 @@
+import {
+ Box,
+ Button,
+ Dialog,
+ DialogContent,
+ DialogTitle,
+ Typography,
+} from "@mui/material";
+
+/**
+ * A dialog box displaying the disclaimer policy for the service.
+ *
+ * @param {Object} props
+ * @param {boolean} [props.open]
+ * @param {function} [props.onClose] - Callback function for closing dialog
+ * @returns {@mui.material.Dialog}
+ */
+const DisclaimerPolicy = ({ open, onClose }) => {
+ return (
+
+ );
+};
+
+export default DisclaimerPolicy;
diff --git a/client/src/components/dialog/PrivacyNotice.js b/client/src/components/dialog/PrivacyNotice.js
new file mode 100644
index 00000000..36aa251c
--- /dev/null
+++ b/client/src/components/dialog/PrivacyNotice.js
@@ -0,0 +1,257 @@
+import {
+ Box,
+ Button,
+ Dialog,
+ DialogContent,
+ DialogTitle,
+ Typography,
+} from "@mui/material";
+
+/**
+ * A dialog box displaying the privacy notice for the service.
+ *
+ * @param {Object} props
+ * @param {boolean} [props.open]
+ * @param {function} [props.onClose] - Callback function for closing dialog
+ * @returns {@mui.material.Dialog}
+ */
+const PrivacyNotice = ({ open, onClose }) => {
+ return (
+
+ );
+};
+
+export default PrivacyNotice;
diff --git a/client/src/components/confirmationDialog.js b/client/src/components/dialog/confirmationDialog.js
similarity index 59%
rename from client/src/components/confirmationDialog.js
rename to client/src/components/dialog/confirmationDialog.js
index b8b4ce05..af768655 100644
--- a/client/src/components/confirmationDialog.js
+++ b/client/src/components/dialog/confirmationDialog.js
@@ -1,23 +1,27 @@
import {
+ Button,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
- Button,
} from "@mui/material";
-// Reusable confirmation dialog with fully controlled content/styles by caller.
-// Props:
-// - open: boolean
-// - title: string | ReactNode
-// - message: string | ReactNode
-// - confirm: () => void
-// - cancel: () => void
-// - confirmText: string
-// - cancelText: string
-// - confirmColor: 'primary' | 'secondary' | 'error' | 'info' | 'success' | 'warning'
-// - cancelColor: same as above
+/**
+ * A confirmation dialog with fully controlled content/styles by caller.
+ *
+ * @param {Object} props
+ * @param {boolean} [props.open]
+ * @param {string | ReactNode} [props.title]
+ * @param {string | ReactNode} [props.message]
+ * @param {function} [props.confirm]
+ * @param {function} [props.cancel]
+ * @param {string} [props.confirmText]
+ * @param {string} [props.cancelText]
+ * @param {string} [props.confirmColor] primary, secondary, error, info, success, warning
+ * @param {string} [props.cancelColor] Same as above
+ * @returns {@mui.material.Dialog}
+ */
const ConfirmationDialog = ({
open,
title,
diff --git a/client/src/components/BloodReportUpload.js b/client/src/components/healthReport/BloodReportUpload.js
similarity index 82%
rename from client/src/components/BloodReportUpload.js
rename to client/src/components/healthReport/BloodReportUpload.js
index 87a6f58e..b65e3335 100644
--- a/client/src/components/BloodReportUpload.js
+++ b/client/src/components/healthReport/BloodReportUpload.js
@@ -3,6 +3,14 @@ import { styled } from "@mui/material/styles";
import FileUploadIcon from "@mui/icons-material/FileUpload";
import pdfToText from "react-pdftotext";
+/**
+ * A button that allows a user to select and submit a blood report that
+ * will auto fill sections of the health report form.
+ *
+ * @param {Object} props
+ * @param {function} [props.onChange] - Callback function called when files are changed
+ * @returns {@mui.material.Button}
+ */
const BloodReportUpload = ({ onChange }) => {
const HiddenInput = styled("input")({
clip: "rect(0 0 0 0)",
@@ -20,7 +28,7 @@ const BloodReportUpload = ({ onChange }) => {
const file = e.target.files[0];
await pdfToText(file)
.then((text) => onChange?.(extractPatientInfoAsDict(text)))
- .catch((error) => console.error(error));
+ .catch((error) => console.error("Failed to process blood report."));
}
function extractPatientInfoAsDict(text) {
@@ -65,6 +73,10 @@ const BloodReportUpload = ({ onChange }) => {
variant="contained"
tabIndex={-1}
startIcon={}
+ sx={{
+ justifyContent: "center",
+ textAlign: "center",
+ }}
>
Upload Blood Report
diff --git a/client/src/components/DownloadReportButton.js b/client/src/components/healthReport/DownloadReportButton.js
similarity index 54%
rename from client/src/components/DownloadReportButton.js
rename to client/src/components/healthReport/DownloadReportButton.js
index 43e9b5fa..5517769c 100644
--- a/client/src/components/DownloadReportButton.js
+++ b/client/src/components/healthReport/DownloadReportButton.js
@@ -1,8 +1,10 @@
-import { pdf } from '@react-pdf/renderer';
-import { saveAs } from 'file-saver';
-import HealthReportPDFFlat from './HealthReportPDFFlat';
-import { Button } from '@mui/material';
-import DownloadIcon from '@mui/icons-material/Download';
+import { pdf } from "@react-pdf/renderer";
+import { saveAs } from "file-saver";
+import HealthReportPDFFlat from "./HealthReportPDFFlat";
+import { Button } from "@mui/material";
+import DownloadIcon from "@mui/icons-material/Download";
+import html2canvas from "html2canvas";
+
/**
* A simple button component to trigger a PDF report download for a specific health data ID.
@@ -16,9 +18,15 @@ import DownloadIcon from '@mui/icons-material/Download';
* @param {object} [props.flatReportData] - Already-fetched flat report data from /getReportData/{id}.
* @param {object} [props.meta] - Additional metadata: { date, healthDataID, fileNameHint }.
* @param {function} props.onError - Callback function to handle errors.
+ * @returns {@mui.material.Button}
*/
-const DownloadReportButton = ({ healthDataId, flatReportData, meta, onError }) => {
-
+const DownloadReportButton = ({
+ healthDataId,
+ flatReportData,
+ meta,
+ onError,
+ chartRef,
+}) => {
const handleDownload = async () => {
if (!flatReportData && !healthDataId) {
onError?.("Health Data ID is required.");
@@ -28,32 +36,46 @@ const DownloadReportButton = ({ healthDataId, flatReportData, meta, onError }) =
try {
// Build local date string for filename to avoid timezone shift
const buildLocalDatePart = (value) => {
- if (!value) return 'report';
+ if (!value) return "report";
const d = new Date(value);
- if (Number.isNaN(d.getTime())) return 'report';
+ if (Number.isNaN(d.getTime())) return "report";
const y = d.getFullYear();
- const m = String(d.getMonth() + 1).padStart(2, '0');
- const day = String(d.getDate()).padStart(2, '0');
+ const m = String(d.getMonth() + 1).padStart(2, "0");
+ const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
};
// Generate from flat data (page already has it)
- const fileName = meta?.fileNameHint
- || `HealthReport_${String(meta?.healthDataID ?? healthDataId)}_${buildLocalDatePart(meta?.date)}.pdf`;
+ const fileName =
+ meta?.fileNameHint ||
+ `HealthReport_${String(meta?.healthDataID ?? healthDataId)}_${buildLocalDatePart(meta?.date)}.pdf`;
+
+ const canvas = await html2canvas(chartRef.current, {scale: 2});
+ const chartImage = canvas.toDataURL("image/png");
+
const blob = await pdf(
-
+ ,
).toBlob();
saveAs(blob, fileName);
if (onError) onError?.(null); // Clear previous errors on success
-
} catch (error) {
- console.error("Failed to download report:", error);
+ console.error("Failed to download report.");
if (onError) onError(error.message);
}
};
return (
- }>
+ }
+ >
Download Report
);
diff --git a/client/src/components/GenerateReportForm.js b/client/src/components/healthReport/GenerateReportForm.js
similarity index 76%
rename from client/src/components/GenerateReportForm.js
rename to client/src/components/healthReport/GenerateReportForm.js
index abd69ee1..949ef6f8 100644
--- a/client/src/components/GenerateReportForm.js
+++ b/client/src/components/healthReport/GenerateReportForm.js
@@ -1,33 +1,40 @@
-import { useState, useEffect } from "react";
-
-import { useNavigate } from "react-router-dom";
+import CheckBoxIcon from "@mui/icons-material/CheckBox";
+import CheckBoxOutlineBlankIcon from "@mui/icons-material/CheckBoxOutlineBlank";
import {
- Card,
- CardHeader,
- CardContent,
Box,
- Typography,
- TextField,
Button,
+ Card,
+ CardContent,
+ FormControl,
+ FormHelperText,
InputLabel,
+ ListItemText,
MenuItem,
OutlinedInput,
- FormControl,
- ListItemText,
Select,
- FormHelperText,
+ TextField,
+ Typography,
+ useMediaQuery,
+ useTheme,
} from "@mui/material";
-
-import CheckBoxOutlineBlankIcon from "@mui/icons-material/CheckBoxOutlineBlank";
-import CheckBoxIcon from "@mui/icons-material/CheckBox";
-
+import { useEffect, useState } from "react";
+import { useNavigate } from "react-router-dom";
import BloodReportUpload from "./BloodReportUpload";
const API_BASE = process.env.REACT_APP_API_URL || "http://localhost:8000";
+/**
+ * A form that can be filled in by a standard user with their health
+ * information to generate a health report.
+ *
+ * @returns {@mui.material.Card}
+ */
const GenerateReportForm = () => {
const navigate = useNavigate();
+ const theme = useTheme();
+ const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
+ // Options for each drop down input. This can be modified as required to change the input for each selection
const healthConditions = [
"Hyper Tension",
"Heart Disease",
@@ -35,7 +42,22 @@ const GenerateReportForm = () => {
"High Cholesterol",
"Stroke",
];
- const lifeStyleChoices = ["Drink Alcohol", "Current Smoker", "Former Smoker"];
+
+ const smokerOptions = ["No", "Yes", "Former smoker"];
+
+ const maritalStatusOptions = ["Single", "Married", "Widow", "Divorced"];
+
+ const workingStatusOptions = [
+ "Unemployed",
+ "Homemaker",
+ "Student",
+ "Working",
+ "Retired",
+ ];
+
+ const raceOptions = ["Malay", "Chinese", "Indian", "Other"];
+
+ const alcoholOptions = ["Regular", "Occasional", "Non-drinker"];
const ITEM_HEIGHT = 48;
const ITEM_PADDING_TOP = 8;
@@ -49,7 +71,6 @@ const GenerateReportForm = () => {
};
const [condition, setCondition] = useState([]);
- const [lifeStyle, setLifeStyle] = useState([]);
const [weight, setWeight] = useState(null);
const [alertWeightRequired, setAlertWeightRequired] = useState(false);
@@ -66,13 +87,19 @@ const GenerateReportForm = () => {
const [alertApLowRequired, setAlertApLowRequired] = useState(false);
const [apHigh, setApHigh] = useState(null);
const [alertApHighRequired, setAlertApHighRequired] = useState(false);
- const [maritalStatus, setMaritalStatus] = useState(null);
+ const [maritalStatus, setMaritalStatus] = useState("");
const [alertMaritalStatusRequired, setAlertMaritalStatusRequired] =
useState(false);
- const [workingStatus, setWorkingStatus] = useState(null);
+ const [workingStatus, setWorkingStatus] = useState("");
const [alertWorkingStatusRequired, setAlertWorkingStatusRequired] =
useState(false);
const [bloodGlucoseInput, setBloodGlucoseInput] = useState("");
+ const [race, setRace] = useState("");
+ const [alertRaceRequired, setAlertRaceRequired] = useState(false);
+ const [smoker, setSmoker] = useState("");
+ const [alertSmokerRequired, setAlertSmokerRequired] = useState(false);
+ const [alcohol, setAlcohol] = useState("");
+ const [alertAlcoholRequired, setAlertAlcoholRequired] = useState(false);
const [isLoading, setIsLoading] = useState(false);
@@ -92,8 +119,11 @@ const GenerateReportForm = () => {
setHeight({ isValid: true, value: data.height });
setGender(data.gender);
setAge({ isValid: true, value: data.age });
+ setMaritalStatus(data.maritalStatus);
+ setWorkingStatus(data.workingStatus);
+ setRace(data.race);
} catch (err) {
- console.log("Failed to fetch patient data.");
+ console.error("Failed to fetch patient data.");
}
}
fetchPatientData();
@@ -106,32 +136,6 @@ const GenerateReportForm = () => {
setCondition(typeof value === "string" ? value.split(",") : value);
}
- function handleChangeLifeStyle(e) {
- const {
- target: { value },
- } = e;
-
- let newValues = typeof value === "string" ? value.split(",") : value;
- const lastSelected = newValues[newValues.length - 1];
-
- // Remove former smoker if the user selects they are a current smoker
- if (
- lastSelected === "Current Smoker" &&
- newValues.includes("Former Smoker")
- ) {
- newValues = newValues.filter((smoker) => smoker !== "Former Smoker");
- }
-
- // Remove current smoker if the user selects they are a former smoker
- if (
- lastSelected === "Former Smoker" &&
- newValues.includes("Current Smoker")
- ) {
- newValues = newValues.filter((smoker) => smoker !== "Current Smoker");
- }
- setLifeStyle(newValues);
- }
-
function updateAge(e) {
const ageValue = Number(e.target.value);
const isAgeValid =
@@ -200,6 +204,19 @@ const GenerateReportForm = () => {
setAlertWorkingStatusRequired(false);
}
+ function updateRace(e) {
+ setRace(e.target.value);
+ setAlertRaceRequired(false);
+ }
+ function updateSmoker(e) {
+ setSmoker(e.target.value);
+ setAlertSmokerRequired(false);
+ }
+ function updateAlcohol(e) {
+ setAlcohol(e.target.value);
+ setAlertAlcoholRequired(false);
+ }
+
function isAllInputsValid() {
return (
weight !== null &&
@@ -215,8 +232,11 @@ const GenerateReportForm = () => {
apLow.isValid &&
apHigh !== null &&
apHigh.isValid &&
- maritalStatus !== null &&
- workingStatus !== null
+ maritalStatus !== "" &&
+ workingStatus !== "" &&
+ race !== "" &&
+ smoker !== "" &&
+ alcohol !== ""
);
}
function updateAllInputFieldAlerts() {
@@ -229,13 +249,16 @@ const GenerateReportForm = () => {
);
setAlertApLowRequired(apLow === null || !apLow.isValid);
setAlertApHighRequired(apHigh === null || !apHigh.isValid);
- setAlertMaritalStatusRequired(maritalStatus === null);
- setAlertWorkingStatusRequired(workingStatus === null);
+ setAlertMaritalStatusRequired(maritalStatus === "");
+ setAlertWorkingStatusRequired(workingStatus === "");
+ setAlertRaceRequired(race === "");
+ setAlertSmokerRequired(smoker === "");
+ setAlertAlcoholRequired(alcohol === "");
}
// Fills in fields with information found in the blood reports.
async function readBloodReport(e) {
- if (e.aveBloodGlucose !== NaN) {
+ if (!isNaN(e.aveBloodGlucose)) {
// Value needs to be in a specific dictionary format to be validated and set.
updateBloodGlucose({ target: { value: e.aveBloodGlucose.toString() } });
}
@@ -268,14 +291,6 @@ const GenerateReportForm = () => {
const highCholesterol = condition.includes("High Cholesterol") ? 1 : 0;
const stroke = condition.includes("Stroke") ? 1 : 0;
- // Get lifestyle values for fetch request
- const alcohol = lifeStyle.includes("Drink Alcohol") ? 1 : 0;
- const smoker = lifeStyle.includes("Current Smoker")
- ? "Yes"
- : lifeStyle.includes("Former Smoker")
- ? "Former smoker"
- : "No";
-
// Fetch request for AI Model
await fetch(`${API_BASE}/health-prediction`, {
method: "POST",
@@ -300,10 +315,12 @@ const GenerateReportForm = () => {
maritalStatus: maritalStatus,
workingStatus: workingStatus,
stroke: stroke,
+ race: race,
}),
})
.then((response) => {
if (!response.ok) {
+ setIsLoading(false);
throw new Error(response.status);
}
setIsLoading(false);
@@ -314,7 +331,7 @@ const GenerateReportForm = () => {
navigate("/report-history"); // Route the user to the Health prediction page after submission
})
.catch((error) => {
- console.log("An error has occurred");
+ console.error("An error has occurred.");
});
}
@@ -335,7 +352,7 @@ const GenerateReportForm = () => {
}}
>
{
{/* Age & Physique Section */}
Age & Physique
@@ -449,10 +467,11 @@ const GenerateReportForm = () => {
{/* Fitness Section */}
Health & Fitness
@@ -544,14 +563,12 @@ const GenerateReportForm = () => {
}
/>
-
- {/*Multi-select LifeStyle Habits */}
-
Life Style
@@ -564,41 +581,53 @@ const GenerateReportForm = () => {
gap: 3,
}}
>
-
-
- Life Style Habits (if any)
-
+ {/* Smoking Status Selection */}
+
+ Smoking Status
+
}
- 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
+ )}
+
-
+
{/* Age & Physique Section */}
Age & Physique
@@ -605,10 +614,11 @@ const MerchantReportForm = () => {
{/* Fitness Section */}
Health & Fitness
@@ -704,10 +714,11 @@ const MerchantReportForm = () => {
{/*Multi-select LifeStyle Habits */}
Life Style
@@ -720,41 +731,53 @@ const MerchantReportForm = () => {
gap: 3,
}}
>
-
-
- Life Style Habits (if any)
-
+ {/* Smoking Status Selection */}
+
+ Smoking Status
+
}
- 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 */}
@@ -767,8 +790,11 @@ const MerchantReportForm = () => {
onChange={updateMaritalStatus}
label="Marital Status"
>
- Single
- Married
+ {maritalStatusOptions.map((option) => (
+
+ {option}
+
+ ))}
{alertMaritalStatusRequired && (
@@ -786,10 +812,11 @@ const MerchantReportForm = () => {
label="Working Status"
onChange={updateWorkingStatus}
>
- Unemployed
- Private
- Student
- Public
+ {workingStatusOptions.map((option) => (
+
+ {option}
+
+ ))}
{alertWorkingStatusRequired && (
@@ -797,6 +824,27 @@ const MerchantReportForm = () => {
)}
+
+ Race
+
+
+
+ {alertRaceRequired && (
+ *Please enter your race
+ )}
+
diff --git a/client/src/components/healthReport/PDFHealthChart.js b/client/src/components/healthReport/PDFHealthChart.js
new file mode 100644
index 00000000..9fd2d52b
--- /dev/null
+++ b/client/src/components/healthReport/PDFHealthChart.js
@@ -0,0 +1,245 @@
+import React, { useMemo, forwardRef } from "react";
+import {
+ Box,
+ Typography,
+ Card,
+ CardContent,
+ useTheme,
+} from "@mui/material";
+import { LineChart } from "@mui/x-charts/LineChart";
+
+const formatXAxisLabel = (label, index, total) => {
+ if (!label) {
+ return label;
+ }
+
+ // Thin out middle ticks for crowded datasets on smaller screens
+ if (total > 4 && index > 0 && index < total - 1) {
+ if (total > 6 && index % 2 === 1) {
+ return "";
+ }
+ }
+
+ if (/^\d{4}-\d{2}-\d{2}$/.test(label)) {
+ const [, month, day] = label.split("-");
+ return `${month}/${day}`;
+ }
+
+ const parts = label.split(" ");
+ if (parts.length === 2 && parts[1].length === 4) {
+ return parts[0];
+ }
+
+ return label;
+};
+
+/**
+ * A Component that displays the trends in the user's health based on
+ * their report history. Used for PDF download.
+ *
+ * @returns {@mui.material.Box}
+ */
+const PDFHealthChart = forwardRef(({ healthData }, ref) => {
+ const theme = useTheme();
+
+ // Helpers for aggregation and y-axis scaling
+ const colors = [theme.palette.primary.main, "#ff7043", "#42a5f5"];
+
+ const { xAxisData, chartSeries, yAxisMax } = useMemo(() => {
+ if (!healthData || healthData.length === 0) {
+ return { xAxisData: [], chartSeries: [], yAxisMax: 60 };
+ }
+
+ // Parse dates and sort ascending
+ const parsed = healthData
+ .map((d) => {
+ const dt = d.date ? new Date(d.date) : null;
+ return { ...d, _dateObj: dt };
+ })
+ .filter((d) => d._dateObj && !isNaN(d._dateObj.getTime()))
+ .sort((a, b) => a._dateObj - b._dateObj);
+
+ if (parsed.length === 0) {
+ return { xAxisData: [], chartSeries: [], yAxisMax: 60 };
+ }
+
+ const minDate = parsed[0]._dateObj;
+ const maxDate = parsed[parsed.length - 1]._dateObj;
+ const crossYear = minDate.getFullYear() !== maxDate.getFullYear();
+ const diffDays = (maxDate - minDate) / (1000 * 60 * 60 * 24);
+
+ const needAggregateMonthly =
+ crossYear || diffDays > 180 || parsed.length > 20;
+
+ let working = [];
+ let xLabels = [];
+
+ if (needAggregateMonthly) {
+ // Group by year-month and compute averages
+ const groups = new Map();
+ for (const d of parsed) {
+ const y = d._dateObj.getFullYear();
+ const m = d._dateObj.getMonth(); // 0-11
+ const key = `${y}-${m}`;
+ if (!groups.has(key)) {
+ groups.set(key, { y, m, items: [] });
+ }
+ groups.get(key).items.push(d);
+ }
+
+ const monthNames = [
+ "Jan",
+ "Feb",
+ "Mar",
+ "Apr",
+ "May",
+ "Jun",
+ "Jul",
+ "Aug",
+ "Sep",
+ "Oct",
+ "Nov",
+ "Dec",
+ ];
+ const aggregated = Array.from(groups.values())
+ .sort((a, b) => a.y - b.y || a.m - b.m)
+ .map(({ y, m, items }) => {
+ const n = items.length || 1;
+ const avg = (arr, key) =>
+ arr.reduce((sum, it) => sum + (Number(it[key]) || 0), 0) / n;
+ return {
+ label: `${monthNames[m]} ${y}`,
+ strokeProbability: avg(items, "strokeProbability"),
+ cardioProbability: avg(items, "cardioProbability"),
+ diabetesProbability: avg(items, "diabetesProbability"),
+ };
+ });
+
+ working = aggregated;
+ xLabels = aggregated.map((a) => a.label);
+ } else {
+ // Use precise date points; format label as YYYY-MM-DD
+ const formatDate = (dt) => {
+ const y = dt.getFullYear();
+ const m = String(dt.getMonth() + 1).padStart(2, "0");
+ const d = String(dt.getDate()).padStart(2, "0");
+ return `${y}-${m}-${d}`;
+ };
+ working = parsed;
+ xLabels = parsed.map((d) => formatDate(d._dateObj));
+ }
+
+ // Build series based on selection
+ const series = [
+ {
+ data: working.map((w) => Number(w.strokeProbability) || 0),
+ label: "Stroke Probability (%)",
+ color: colors[0],
+ },
+ {
+ data: working.map((w) => Number(w.cardioProbability) || 0),
+ label: "Cardio Probability (%)",
+ color: colors[1],
+ },
+ {
+ data: working.map((w) => Number(w.diabetesProbability) || 0),
+ label: "Diabetes Probability (%)",
+ color: colors[2],
+ }
+ ];
+
+ // Dynamic y-axis max with padding, capped at 100
+ let maxVal = 0;
+ for (const s of series) {
+ for (const v of s.data) maxVal = Math.max(maxVal, Number(v) || 0);
+ }
+ const padded = Math.min(100, Math.max(0, maxVal + 5));
+ const yMax = Math.max(10, Math.min(100, Math.ceil(padded / 10) * 10));
+
+ return { xAxisData: xLabels, chartSeries: series, yAxisMax: yMax };
+ }, [healthData, colors]);
+
+ const xAxisValueFormatter = useMemo(() => {
+ if (!xAxisData || xAxisData.length === 0) {
+ return undefined;
+ }
+
+ return (value) => {
+ const index = xAxisData.indexOf(value);
+ if (index === -1) {
+ return value;
+ }
+ return formatXAxisLabel(value, index, xAxisData.length);
+ };
+ }, [xAxisData]);
+
+ return (
+
+
+
+
+
+
+
+
+
+ );
+});
+
+export default PDFHealthChart;
\ No newline at end of file
diff --git a/client/src/components/ReportTemplate.js b/client/src/components/healthReport/ReportTemplate.js
similarity index 92%
rename from client/src/components/ReportTemplate.js
rename to client/src/components/healthReport/ReportTemplate.js
index 9df107ab..45e397b6 100644
--- a/client/src/components/ReportTemplate.js
+++ b/client/src/components/healthReport/ReportTemplate.js
@@ -1,16 +1,21 @@
import {
Box,
- Typography,
- List,
- ListItem,
- ListItemText,
- Divider,
Card,
Paper,
- useTheme,
+ Typography,
useMediaQuery,
+ useTheme,
} from "@mui/material";
+/**
+ * A template that can be filled in with health data from generated from
+ * reports and displays them in a readable format.
+ *
+ * @param {Object} props
+ * @param {Object} [props.report] - Health data in the report.
+ * @param {Object} [props.date] - Date the report was generated.
+ * @returns {@mui.material.Box}
+ */
const ReportTemplate = ({ report, date }) => {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
@@ -146,6 +151,8 @@ const ReportTemplate = ({ report, date }) => {
}}
>
Generated on: {new Date(date).toLocaleDateString("en-AU")}
+ {" at "}
+ {`${new Date(date).toLocaleTimeString("en-AU")}`}
@@ -256,7 +263,7 @@ const ReportTemplate = ({ report, date }) => {
}}
>
{
p: 2,
}}
>
- {/* Lifestyle */}
-
-
- Lifestyle Recommendations
-
-
- {report.lifestyleRecommendation ||
- "No lifestyle recommendation available."}
-
-
-
{/* Exercise */}
-
+
Exercise Recommendations
@@ -296,7 +292,7 @@ const ReportTemplate = ({ report, date }) => {
{/* Diet */}
-
+
Diet Recommendations
@@ -304,9 +300,20 @@ const ReportTemplate = ({ report, date }) => {
+ {/* Lifestyle */}
+
+
+ Lifestyle Recommendations
+
+
+ {report.lifestyleRecommendation ||
+ "No lifestyle recommendation available."}
+
+
+
{/* Diet to Avoid */}
-
+
Diet to Avoid
diff --git a/client/src/components/AcceptRequestForm.js b/client/src/components/patientAccessRequest/AcceptRequestForm.js
similarity index 89%
rename from client/src/components/AcceptRequestForm.js
rename to client/src/components/patientAccessRequest/AcceptRequestForm.js
index d21c7e3f..c3213736 100644
--- a/client/src/components/AcceptRequestForm.js
+++ b/client/src/components/patientAccessRequest/AcceptRequestForm.js
@@ -1,23 +1,24 @@
-import { useState, useEffect } from "react";
-import { useParams, Link as RouterLink } from "react-router-dom";
import {
Box,
- Container,
- Stack,
Button,
- Typography,
- Link,
+ Card,
+ Divider,
List,
ListItem,
ListItemText,
- Divider,
+ Stack,
+ Typography,
} from "@mui/material";
+import { useState } from "react";
+import { Link as RouterLink, useParams } from "react-router-dom";
const API_BASE = process.env.REACT_APP_API_URL || "http://localhost:8000";
/**
- * Allows the user to accept an access request from a merchant user.
- * This will allow the merchant to view manage the patients record.
+ * A form that allows a user to accept an access request from a merchant
+ * and grant the permission to view their health records.
+ *
+ * @returns {@mui.material.Container}
*/
const AcceptRequestForm = () => {
const [isLoading, setIsLoading] = useState(false);
@@ -54,14 +55,11 @@ const AcceptRequestForm = () => {
}
return (
-
@@ -75,7 +73,7 @@ const AcceptRequestForm = () => {
style={{ justifyContent: "center" }}
>
-
+
Partner Access Request
@@ -157,7 +155,7 @@ const AcceptRequestForm = () => {
)}
-
+
);
};
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.
-
{
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
-
setDeleteDialogOpen(true)}
- sx={{
- mt: 0,
- minWidth: "auto",
- width: "auto",
- px: { xs: 1.75, sm: 2.25 },
- py: { xs: "0.45rem", sm: "0.5rem" },
- fontSize: { xs: "0.82rem", sm: "0.85rem" },
- lineHeight: 1.2,
- borderRadius: 1.5,
- fontWeight: 600,
- }}
+ sx={{ width: isMobile ? "100%" : "auto" }}
>
{deleteBusy ? "Deleting…" : "Delete Account"}
{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"
- />
-
-
- handleSave("Notifications")}
- sx={{
- px: 4,
- py: { xs: "0.8rem", sm: "0.6rem" },
- fontSize: { xs: "1rem", sm: "0.875rem" },
- fontWeight: 500,
- }}
- >
- Save Preferences
-
-
-
-
- );
-
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(':')