From 5f4ea783006bf425b92fc0da10fe13eb914422ce Mon Sep 17 00:00:00 2001 From: Ravi Gulhane <> Date: Fri, 23 Oct 2026 10:40:19 +0530 Subject: [PATCH] first commit --- README.md | 67 + backend/.env.example | 29 + backend/.gitignore | 4 + backend/package-lock.json | 1046 +++++++++++++ backend/package.json | 29 + backend/src/config/db.js | 66 + backend/src/controllers/authController.js | 76 + .../src/controllers/dashboardController.js | 55 + backend/src/controllers/doctorController.js | 59 + backend/src/controllers/surveyController.js | 208 +++ backend/src/middleware/auth.js | 39 + backend/src/middleware/rateLimit.js | 24 + backend/src/questionnaire.js | 332 ++++ backend/src/routes/authRoutes.js | 18 + backend/src/routes/dashboardRoutes.js | 21 + backend/src/routes/doctorRoutes.js | 20 + backend/src/routes/surveyRoutes.js | 36 + backend/src/seed.js | 32 + backend/src/server.js | 57 + backend/src/services/rapport.js | 183 +++ frontend/.env.example | 1 + frontend/.gitignore | 6 + frontend/.oxlintrc.json | 8 + frontend/README.md | 16 + frontend/index.html | 13 + frontend/package-lock.json | 1374 +++++++++++++++++ frontend/package.json | 24 + frontend/public/favicon.svg | 1 + frontend/public/icons.svg | 24 + frontend/src/App.css | 306 ++++ frontend/src/App.jsx | 21 + frontend/src/api.js | 83 + frontend/src/assets/hero.png | Bin 0 -> 13057 bytes frontend/src/assets/react.svg | 1 + frontend/src/assets/vite.svg | 1 + frontend/src/auth.js | 28 + frontend/src/components/QuestionField.jsx | 96 ++ frontend/src/index.css | 40 + frontend/src/main.jsx | 10 + frontend/src/pages/DashboardPage.jsx | 130 ++ frontend/src/pages/HomePage.jsx | 29 + frontend/src/pages/LoginPage.jsx | 76 + frontend/src/pages/SurveyPage.jsx | 406 +++++ frontend/src/questionnaire.js | 314 ++++ frontend/vite.config.js | 7 + 45 files changed, 5416 insertions(+) create mode 100644 README.md create mode 100644 backend/.env.example create mode 100644 backend/.gitignore create mode 100644 backend/package-lock.json create mode 100644 backend/package.json create mode 100644 backend/src/config/db.js create mode 100644 backend/src/controllers/authController.js create mode 100644 backend/src/controllers/dashboardController.js create mode 100644 backend/src/controllers/doctorController.js create mode 100644 backend/src/controllers/surveyController.js create mode 100644 backend/src/middleware/auth.js create mode 100644 backend/src/middleware/rateLimit.js create mode 100644 backend/src/questionnaire.js create mode 100644 backend/src/routes/authRoutes.js create mode 100644 backend/src/routes/dashboardRoutes.js create mode 100644 backend/src/routes/doctorRoutes.js create mode 100644 backend/src/routes/surveyRoutes.js create mode 100644 backend/src/seed.js create mode 100644 backend/src/server.js create mode 100644 backend/src/services/rapport.js create mode 100644 frontend/.env.example create mode 100644 frontend/.gitignore create mode 100644 frontend/.oxlintrc.json create mode 100644 frontend/README.md create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/public/icons.svg create mode 100644 frontend/src/App.css create mode 100644 frontend/src/App.jsx create mode 100644 frontend/src/api.js create mode 100644 frontend/src/assets/hero.png create mode 100644 frontend/src/assets/react.svg create mode 100644 frontend/src/assets/vite.svg create mode 100644 frontend/src/auth.js create mode 100644 frontend/src/components/QuestionField.jsx create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.jsx create mode 100644 frontend/src/pages/DashboardPage.jsx create mode 100644 frontend/src/pages/HomePage.jsx create mode 100644 frontend/src/pages/LoginPage.jsx create mode 100644 frontend/src/pages/SurveyPage.jsx create mode 100644 frontend/src/questionnaire.js create mode 100644 frontend/vite.config.js diff --git a/README.md b/README.md new file mode 100644 index 0000000..63f35dc --- /dev/null +++ b/README.md @@ -0,0 +1,67 @@ +# TGICS (The Great Indian Cough Study) + +Doctor magic-link patient questionnaire for LifeMankind / Rapport. + +- Frontend: `https://tgics.cttr.in/{user_id}/{contact_id}` +- Backend: Hapi + MySQL +- Rapport partition: `rapport-lifemankind` + +## Structure + +``` +tgics/ + backend/ # Node.js Hapi API + frontend/ # Vite + React +``` + +## Backend + +```bash +cd backend +cp .env.example .env +# set DB_* and RAPPORT_ACCESS_TOKEN +npm install +npm run dev +``` + +API defaults to `http://localhost:3050`. + +| Method | Path | Purpose | +|--------|------|---------| +| GET | `/health` | Health | +| GET | `/api/doctors/{userId}/{contactId}` | Resolve enrolled doctor | +| POST | `/api/surveys` | Save patient survey | +| GET | `/api/surveys/counts?contact_ids=a,b` | Batch counts for Rapport | +| GET | `/api/surveys/count?contact_id=` | Single count | +| POST | `/api/auth/login` | Dashboard login | +| GET | `/api/auth/me` | Current user (Bearer token) | +| GET | `/api/dashboard/stats` | Survey stats (auth) | +| GET | `/api/dashboard/surveys` | Recent surveys (auth) | + +### Dashboard users + +On startup the API ensures a `users` table and seeds the first admin if missing: + +- Email: `ravi@stratawiz.com` +- Password: `qwerty` + +Login UI: `/login` → `/dashboard` + +## Frontend + +```bash +cd frontend +cp .env.example .env +npm install +npm run dev +``` + +Set `VITE_API_BASE=http://localhost:3050`. + +## Nginx (production sketch) + +Serve the React build on `tgics.cttr.in` and proxy `/api` (and `/health`) to the Hapi process, **or** set `VITE_API_BASE` to a separate API host. + +## Rapport Laravel + +Set `TGICS_API_BASE` in Laravel `.env` (e.g. `https://tgics.cttr.in` or `http://localhost:3050`). The enrolment form uses `App\Helpers\TgicsHelper` for survey counts. diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..cf36add --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,29 @@ +PORT=3050 +HOST=0.0.0.0 +FRONTEND_ORIGIN=http://localhost:5173,https://tgics.cttr.in + +# MySQL Configuration +DB_HOST= +DB_USER= +DB_PASSWORD= +DB_NAME=tgics +DB_PORT=3306 + +# AWS Configuration (optional; reserved for future uploads) +AWS_REGION=ap-south-1 +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_BUCKET= +AWS_URL= + +# Rapport contact API +RAPPORT_API_BASE=https://rapportapi.click/ +RAPPORT_ACCESS_TOKEN= +RAPPORT_PARTITION=rapport-lifemankind + +# Dashboard auth +JWT_SECRET= +JWT_EXPIRES_IN=7d + +# Max survey submits per contact_id per hour +SURVEY_RATE_LIMIT_PER_HOUR=60 diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..daf1dd0 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +.env +*.log +.DS_Store diff --git a/backend/package-lock.json b/backend/package-lock.json new file mode 100644 index 0000000..7b96be9 --- /dev/null +++ b/backend/package-lock.json @@ -0,0 +1,1046 @@ +{ + "name": "tgics-backend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "tgics-backend", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@hapi/hapi": "^21.4.10", + "bcrypt": "^6.0.0", + "dotenv": "^16.4.7", + "jsonwebtoken": "^9.0.3", + "mysql2": "^3.14.0" + }, + "devDependencies": { + "@types/bcrypt": "^6.0.0", + "@types/jsonwebtoken": "^9.0.10", + "nodemon": "^3.1.9" + } + }, + "node_modules/@hapi/accept": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@hapi/accept/-/accept-6.0.3.tgz", + "integrity": "sha512-p72f9k56EuF0n3MwlBNThyVE5PXX40g+aQh+C/xbKrfzahM2Oispv3AXmOIU51t3j77zay1qrX7IIziZXspMlw==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/boom": "^10.0.1", + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@hapi/ammo": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@hapi/ammo/-/ammo-6.0.2.tgz", + "integrity": "sha512-YeVEkur58mIJ2wxIuIDGKDgpiNEmLOGswggGarZu/VORUPLBjOUPiaZSl2oFZGECqt3yPSNFBiHTwOQH51U+vA==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@hapi/b64": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@hapi/b64/-/b64-6.0.1.tgz", + "integrity": "sha512-ZvjX4JQReUmBheeCq+S9YavcnMMHWqx3S0jHNXWIM1kQDxB9cyfSycpVvjfrKcIS8Mh5N3hmu/YKo4Iag9g2Kw==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@hapi/boom": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@hapi/boom/-/boom-10.0.1.tgz", + "integrity": "sha512-ERcCZaEjdH3OgSJlyjVk8pHIFeus91CjKP3v+MpgBNp5IvGzP2l/bRiD78nqYcKPaZdbKkK5vDBVPd2ohHBlsA==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@hapi/bounce": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@hapi/bounce/-/bounce-3.0.2.tgz", + "integrity": "sha512-d0XmlTi3H9HFDHhQLjg4F4auL1EY3Wqj7j7/hGDhFFe6xAbnm3qiGrXeT93zZnPH8gH+SKAFYiRzu26xkXcH3g==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/boom": "^10.0.1", + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@hapi/bourne": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@hapi/bourne/-/bourne-3.0.0.tgz", + "integrity": "sha512-Waj1cwPXJDucOib4a3bAISsKJVb15MKi9IvmTI/7ssVEm6sywXGjVJDhl6/umt1pK1ZS7PacXU3A1PmFKHEZ2w==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/call": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@hapi/call/-/call-9.0.1.tgz", + "integrity": "sha512-uPojQRqEL1GRZR4xXPqcLMujQGaEpyVPRyBlD8Pp5rqgIwLhtveF9PkixiKru2THXvuN8mUrLeet5fqxKAAMGg==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/boom": "^10.0.1", + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@hapi/catbox": { + "version": "12.1.1", + "resolved": "https://registry.npmjs.org/@hapi/catbox/-/catbox-12.1.1.tgz", + "integrity": "sha512-hDqYB1J+R0HtZg4iPH3LEnldoaBsar6bYp0EonBmNQ9t5CO+1CqgCul2ZtFveW1ReA5SQuze9GPSU7/aecERhw==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/boom": "^10.0.1", + "@hapi/hoek": "^11.0.2", + "@hapi/podium": "^5.0.0", + "@hapi/validate": "^2.0.1" + } + }, + "node_modules/@hapi/catbox-memory": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@hapi/catbox-memory/-/catbox-memory-6.0.2.tgz", + "integrity": "sha512-H1l4ugoFW/ZRkqeFrIo8p1rWN0PA4MDTfu4JmcoNDvnY975o29mqoZblqFTotxNHlEkMPpIiIBJTV+Mbi+aF0g==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/boom": "^10.0.1", + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@hapi/content": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@hapi/content/-/content-6.0.3.tgz", + "integrity": "sha512-ksXemMNZAwPq/mu1a9TZ7LDUSgMR9sexBgpalF3nrP6SM42ARlnBZs58IS5Ay+o4o7y6tFNXL+rMJkQ5ghvCSw==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/boom": "^10.0.0" + } + }, + "node_modules/@hapi/cryptiles": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@hapi/cryptiles/-/cryptiles-6.0.3.tgz", + "integrity": "sha512-r6VKalpbMHz4ci3gFjFysBmhwCg70RpYZy6OkjEpdXzAYnYFX5XsW7n4YMJvuIYpnMwLxGUjK/cBhA7X3JDvXw==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/boom": "^10.0.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@hapi/file": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@hapi/file/-/file-3.0.0.tgz", + "integrity": "sha512-w+lKW+yRrLhJu620jT3y+5g2mHqnKfepreykvdOcl9/6up8GrQQn+l3FRTsjHTKbkbfQFkuksHpdv2EcpKcJ4Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/hapi": { + "version": "21.4.11", + "resolved": "https://registry.npmjs.org/@hapi/hapi/-/hapi-21.4.11.tgz", + "integrity": "sha512-wmj0pz/MlIkcVRGjIAu6mPC4kNXuHoTuLkD+i14pc5qDTxkpcDcEGbiu5sSuof4ZzD+Wg0gtYmxS7TaXDdoSMQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/accept": "^6.0.3", + "@hapi/ammo": "^6.0.2", + "@hapi/boom": "^10.0.1", + "@hapi/bounce": "^3.0.2", + "@hapi/call": "^9.0.1", + "@hapi/catbox": "^12.1.1", + "@hapi/catbox-memory": "^6.0.2", + "@hapi/heavy": "^8.0.1", + "@hapi/hoek": "^11.0.7", + "@hapi/mimos": "^7.0.1", + "@hapi/podium": "^5.0.2", + "@hapi/shot": "^6.0.3", + "@hapi/somever": "^4.1.1", + "@hapi/statehood": "^8.2.1", + "@hapi/subtext": "^8.1.4", + "@hapi/teamwork": "^6.0.1", + "@hapi/topo": "^6.0.2", + "@hapi/validate": "^2.0.1" + }, + "engines": { + "node": ">=14.15.0" + } + }, + "node_modules/@hapi/heavy": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@hapi/heavy/-/heavy-8.0.1.tgz", + "integrity": "sha512-gBD/NANosNCOp6RsYTsjo2vhr5eYA3BEuogk6cxY0QdhllkkTaJFYtTXv46xd6qhBVMbMMqcSdtqey+UQU3//w==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/boom": "^10.0.1", + "@hapi/hoek": "^11.0.2", + "@hapi/validate": "^2.0.1" + } + }, + "node_modules/@hapi/hoek": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-11.0.7.tgz", + "integrity": "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/iron": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@hapi/iron/-/iron-7.0.1.tgz", + "integrity": "sha512-tEZnrOujKpS6jLKliyWBl3A9PaE+ppuL/+gkbyPPDb/l2KSKQyH4lhMkVb+sBhwN+qaxxlig01JRqB8dk/mPxQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/b64": "^6.0.1", + "@hapi/boom": "^10.0.1", + "@hapi/bourne": "^3.0.0", + "@hapi/cryptiles": "^6.0.1", + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@hapi/mimos": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@hapi/mimos/-/mimos-7.0.1.tgz", + "integrity": "sha512-b79V+BrG0gJ9zcRx1VGcCI6r6GEzzZUgiGEJVoq5gwzuB2Ig9Cax8dUuBauQCFKvl2YWSWyOc8mZ8HDaJOtkew==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2", + "mime-db": "^1.52.0" + } + }, + "node_modules/@hapi/nigel": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@hapi/nigel/-/nigel-5.0.1.tgz", + "integrity": "sha512-uv3dtYuB4IsNaha+tigWmN8mQw/O9Qzl5U26Gm4ZcJVtDdB1AVJOwX3X5wOX+A07qzpEZnOMBAm8jjSqGsU6Nw==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2", + "@hapi/vise": "^5.0.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@hapi/pez": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@hapi/pez/-/pez-6.1.1.tgz", + "integrity": "sha512-yg2OS1tC0S1sHXvhUtWsfRn6lrKl9jKtRhZ+EI0woOW/gqX5vM2PZ1459ypCvCYDRLJ9nIyueeEH5MJV1ZDqIg==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/b64": "^6.0.1", + "@hapi/boom": "^10.0.1", + "@hapi/content": "^6.0.1", + "@hapi/hoek": "^11.0.7", + "@hapi/nigel": "^5.0.1" + } + }, + "node_modules/@hapi/podium": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@hapi/podium/-/podium-5.0.2.tgz", + "integrity": "sha512-T7gf2JYHQQfEfewTQFbsaXoZxSvuXO/QBIGljucUQ/lmPnTTNAepoIKOakWNVWvo2fMEDjycu77r8k6dhreqHA==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2", + "@hapi/teamwork": "^6.0.0", + "@hapi/validate": "^2.0.1" + } + }, + "node_modules/@hapi/shot": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@hapi/shot/-/shot-6.0.3.tgz", + "integrity": "sha512-xKmKONUE5AxUNK+EQCSCz35UpPq4cSy6wRCFvMLCDHdyPeXsmAvwxvU4OemGqpWHtAQHyCPjnDbm/fEU9O6l/w==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2", + "@hapi/validate": "^2.0.1" + } + }, + "node_modules/@hapi/somever": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@hapi/somever/-/somever-4.1.1.tgz", + "integrity": "sha512-lt3QQiDDOVRatS0ionFDNrDIv4eXz58IibQaZQDOg4DqqdNme8oa0iPWcE0+hkq/KTeBCPtEOjDOBKBKwDumVg==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/bounce": "^3.0.1", + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@hapi/statehood": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/@hapi/statehood/-/statehood-8.2.1.tgz", + "integrity": "sha512-xf72TG/QINW26jUu+uL5H+crE1o8GplIgfPWwPZhnAGJzetIVAQEQYvzq+C0aEVHg5/lMMtQ+L9UryuSa5Yjkg==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/boom": "^10.0.1", + "@hapi/bounce": "^3.0.1", + "@hapi/bourne": "^3.0.0", + "@hapi/cryptiles": "^6.0.1", + "@hapi/hoek": "^11.0.2", + "@hapi/iron": "^7.0.1", + "@hapi/validate": "^2.0.1" + } + }, + "node_modules/@hapi/subtext": { + "version": "8.1.4", + "resolved": "https://registry.npmjs.org/@hapi/subtext/-/subtext-8.1.4.tgz", + "integrity": "sha512-z2WWd2UCkW4WbEJFoFOvMyPFGcGFDjYu4ujnDRcUmAMMWRZ1qzhDE5SgfmlsqC53fIii61HWXUmkR/Fn9Py0WQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/boom": "^10.0.1", + "@hapi/bourne": "^3.0.0", + "@hapi/content": "^6.0.2", + "@hapi/file": "^3.0.0", + "@hapi/hoek": "^11.0.7", + "@hapi/pez": "^6.1.1", + "@hapi/wreck": "^18.1.1" + } + }, + "node_modules/@hapi/teamwork": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@hapi/teamwork/-/teamwork-6.0.1.tgz", + "integrity": "sha512-52OXRslUfYwXAOG8k58f2h2ngXYQGP0x5RPOo+eWA/FtyLgHjGMrE3+e9LSXP/0q2YfHAK5wj9aA9DTy1K+kyQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@hapi/topo": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-6.0.2.tgz", + "integrity": "sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@hapi/validate": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@hapi/validate/-/validate-2.0.1.tgz", + "integrity": "sha512-NZmXRnrSLK8MQ9y/CMqE9WSspgB9xA41/LlYR0k967aSZebWr4yNrpxIbov12ICwKy4APSlWXZga9jN5p6puPA==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2", + "@hapi/topo": "^6.0.1" + } + }, + "node_modules/@hapi/vise": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@hapi/vise/-/vise-5.0.1.tgz", + "integrity": "sha512-XZYWzzRtINQLedPYlIkSkUr7m5Ddwlu99V9elh8CSygXstfv3UnWIXT0QD+wmR0VAG34d2Vx3olqcEhRRoTu9A==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@hapi/wreck": { + "version": "18.1.2", + "resolved": "https://registry.npmjs.org/@hapi/wreck/-/wreck-18.1.2.tgz", + "integrity": "sha512-3dMnV2pfhQiyEqu8DL3VBmxkdLiRDiiUDuG79Dp+UK1gL9ZxAfDOUhB6k3D5MLqcgJJ1IARyGFhwoc1NITr/pg==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/boom": "^10.0.1", + "@hapi/bourne": "^3.0.0", + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@types/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.6.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz", + "integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.3.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "license": "MIT" + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lru.min": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.5.tgz", + "integrity": "sha512-5J9ysMYUpYIg9RF2vJpy9SinEmSviFSe0GyPpCQ4L5QSkLAgeLXlTAOu2ZwWUU5m+0SBl6gUU1R1ZQB3aKypfA==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mysql2": { + "version": "3.24.6", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.24.6.tgz", + "integrity": "sha512-enu73KhbAua2L3dIxjc6qg6ZJ7H3pt1BImZASWNWY5xSsmJ1ZuUPULTDGXgmJbI61vb+nJAG19IZclIq19UidQ==", + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.2", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.3", + "long": "^5.3.2", + "lru.min": "^1.1.4", + "named-placeholders": "^1.1.6", + "sql-escaper": "^1.5.1" + }, + "engines": { + "node": ">= 8.0" + }, + "peerDependencies": { + "@types/node": ">= 8" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "license": "MIT", + "dependencies": { + "lru.min": "^1.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/node-addon-api": { + "version": "8.9.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz", + "integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/nodemon": { + "version": "3.1.14", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", + "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^10.2.1", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sql-escaper": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.5.2.tgz", + "integrity": "sha512-6CKD38c31SENivxOADeMNLdukOnUxUcflKtzVWzace7Riv1v7cAEym5Cx9Q7gYZ3ezIJI7ZpqecQq8cqNeSSFg==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=2.0.0", + "node": ">=12.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/mysqljs/sql-escaper?sponsor=1" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "license": "MIT" + } + } +} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..d46f18c --- /dev/null +++ b/backend/package.json @@ -0,0 +1,29 @@ +{ + "name": "tgics-backend", + "version": "1.0.0", + "description": "TGICS (The Great Indian Cough Study) API", + "main": "src/server.js", + "scripts": { + "start": "node src/server.js", + "dev": "nodemon src/server.js" + }, + "keywords": [ + "tgics", + "hapi" + ], + "author": "", + "license": "ISC", + "type": "commonjs", + "dependencies": { + "@hapi/hapi": "^21.4.10", + "bcrypt": "^6.0.0", + "dotenv": "^16.4.7", + "jsonwebtoken": "^9.0.3", + "mysql2": "^3.14.0" + }, + "devDependencies": { + "@types/bcrypt": "^6.0.0", + "@types/jsonwebtoken": "^9.0.10", + "nodemon": "^3.1.9" + } +} diff --git a/backend/src/config/db.js b/backend/src/config/db.js new file mode 100644 index 0000000..bd0ea41 --- /dev/null +++ b/backend/src/config/db.js @@ -0,0 +1,66 @@ +const mysql = require('mysql2/promise'); +require('dotenv').config(); + +const dbName = process.env.DB_NAME || 'tgics'; + +const pool = mysql.createPool({ + host: process.env.DB_HOST || '127.0.0.1', + user: process.env.DB_USER || 'root', + password: process.env.DB_PASSWORD || '', + database: dbName, + port: Number(process.env.DB_PORT || 3306), + waitForConnections: true, + connectionLimit: 10, + queueLimit: 0, +}); + +const initializeDb = async () => { + // Best-effort DB create (may fail on shared RDS without CREATE privilege) + try { + const setup = await mysql.createConnection({ + host: process.env.DB_HOST || '127.0.0.1', + user: process.env.DB_USER || 'root', + password: process.env.DB_PASSWORD || '', + port: Number(process.env.DB_PORT || 3306), + }); + await setup.query(`CREATE DATABASE IF NOT EXISTS \`${dbName}\``); + await setup.end(); + } catch (err) { + console.warn('Skipping CREATE DATABASE (using existing DB):', err.message); + } + + const connection = await pool.getConnection(); + try { + await connection.query(` + CREATE TABLE IF NOT EXISTS users ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(191) NOT NULL, + email VARCHAR(191) NOT NULL, + password_hash VARCHAR(255) NOT NULL, + role VARCHAR(32) NOT NULL DEFAULT 'admin', + status VARCHAR(32) NOT NULL DEFAULT 'active', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uniq_users_email (email) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + `); + + await connection.query(` + CREATE TABLE IF NOT EXISTS surveys ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + contact_id VARCHAR(128) NOT NULL, + user_id VARCHAR(128) NOT NULL, + \`partition\` VARCHAR(64) NOT NULL DEFAULT 'rapport-lifemankind', + has_cough TINYINT(1) NOT NULL DEFAULT 0, + answers_json JSON NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_surveys_contact (contact_id), + INDEX idx_surveys_user_contact (user_id, contact_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + `); + } finally { + connection.release(); + } +}; + +module.exports = { pool, initializeDb }; diff --git a/backend/src/controllers/authController.js b/backend/src/controllers/authController.js new file mode 100644 index 0000000..4f0102a --- /dev/null +++ b/backend/src/controllers/authController.js @@ -0,0 +1,76 @@ +const bcrypt = require('bcrypt'); +const { pool } = require('../config/db'); +const { signUser, getUserFromRequest } = require('../middleware/auth'); + +async function login(request, h) { + try { + const email = String(request.payload?.email || '').trim().toLowerCase(); + const password = String(request.payload?.password || ''); + + if (!email || !password) { + return h.response({ + status: 'error', + message: 'Email and password are required.', + }).code(400); + } + + const [rows] = await pool.execute( + 'SELECT id, name, email, password_hash, role, status FROM users WHERE email = ? LIMIT 1', + [email] + ); + + if (!rows.length) { + return h.response({ status: 'error', message: 'Invalid credentials.' }).code(401); + } + + const user = rows[0]; + if (String(user.status).toLowerCase() !== 'active') { + return h.response({ status: 'error', message: 'Account is inactive.' }).code(403); + } + + const matches = await bcrypt.compare(password, user.password_hash); + if (!matches) { + return h.response({ status: 'error', message: 'Invalid credentials.' }).code(401); + } + + const token = signUser(user); + return h.response({ + status: 'success', + data: { + token, + user: { + id: user.id, + name: user.name, + email: user.email, + role: user.role, + }, + }, + }).code(200); + } catch (err) { + console.error('login error', err); + return h.response({ status: 'error', message: 'Unable to login.' }).code(500); + } +} + +async function me(request, h) { + const auth = getUserFromRequest(request); + if (!auth?.userId) { + return h.response({ status: 'error', message: 'Unauthorized' }).code(401); + } + + try { + const [rows] = await pool.execute( + 'SELECT id, name, email, role, status, created_at FROM users WHERE id = ? LIMIT 1', + [auth.userId] + ); + if (!rows.length) { + return h.response({ status: 'error', message: 'User not found.' }).code(404); + } + return h.response({ status: 'success', data: { user: rows[0] } }).code(200); + } catch (err) { + console.error('me error', err); + return h.response({ status: 'error', message: 'Unable to load profile.' }).code(500); + } +} + +module.exports = { login, me }; diff --git a/backend/src/controllers/dashboardController.js b/backend/src/controllers/dashboardController.js new file mode 100644 index 0000000..9ee2e4e --- /dev/null +++ b/backend/src/controllers/dashboardController.js @@ -0,0 +1,55 @@ +const { pool } = require('../config/db'); + +async function listSurveys(request, h) { + try { + const limit = Math.min(Number(request.query.limit || 50), 200); + const offset = Math.max(Number(request.query.offset || 0), 0); + + const [rows] = await pool.query( + `SELECT id, contact_id, user_id, has_cough, created_at + FROM surveys + ORDER BY created_at DESC + LIMIT ? OFFSET ?`, + [limit, offset] + ); + + const [countRows] = await pool.query('SELECT COUNT(*) AS total FROM surveys'); + const total = Number(countRows[0]?.total || 0); + + return h.response({ + status: 'success', + data: { items: rows, total, limit, offset }, + }).code(200); + } catch (err) { + console.error('listSurveys error', err); + return h.response({ status: 'error', message: 'Unable to load surveys.' }).code(500); + } +} + +async function surveyStats(request, h) { + try { + const [totals] = await pool.query(` + SELECT + COUNT(*) AS total_surveys, + SUM(has_cough = 1) AS with_cough, + SUM(has_cough = 0) AS without_cough, + COUNT(DISTINCT contact_id) AS doctors_with_surveys + FROM surveys + `); + + return h.response({ + status: 'success', + data: { + total_surveys: Number(totals[0]?.total_surveys || 0), + with_cough: Number(totals[0]?.with_cough || 0), + without_cough: Number(totals[0]?.without_cough || 0), + doctors_with_surveys: Number(totals[0]?.doctors_with_surveys || 0), + }, + }).code(200); + } catch (err) { + console.error('surveyStats error', err); + return h.response({ status: 'error', message: 'Unable to load stats.' }).code(500); + } +} + +module.exports = { listSurveys, surveyStats }; diff --git a/backend/src/controllers/doctorController.js b/backend/src/controllers/doctorController.js new file mode 100644 index 0000000..ed1bd28 --- /dev/null +++ b/backend/src/controllers/doctorController.js @@ -0,0 +1,59 @@ +const { resolveTgicsDoctor, resolveTgicsDoctorByPhone } = require('../services/rapport'); +const { QUESTIONS } = require('../questionnaire'); + +async function getDoctor(request, h) { + const { userId, contactId } = request.params; + try { + const result = await resolveTgicsDoctor(userId, contactId); + if (!result.ok) { + return h.response({ status: 'error', message: result.message }).code(result.statusCode); + } + return h.response({ + status: 'success', + data: { + doctor: result.doctor, + questionnaire: QUESTIONS, + }, + }).code(200); + } catch (err) { + const status = err.statusCode || 500; + return h.response({ + status: 'error', + message: err.message || 'Unable to load doctor.', + }).code(status); + } +} + +async function loginDoctorByPhone(request, h) { + const { userId } = request.params; + const phone = String(request.payload?.phone || '').trim(); + + if (!userId) { + return h.response({ + status: 'error', + message: 'Invalid survey link.', + }).code(400); + } + + try { + const result = await resolveTgicsDoctorByPhone(userId, phone); + if (!result.ok) { + return h.response({ status: 'error', message: result.message }).code(result.statusCode); + } + return h.response({ + status: 'success', + data: { + doctor: result.doctor, + questionnaire: QUESTIONS, + }, + }).code(200); + } catch (err) { + const status = err.statusCode || 500; + return h.response({ + status: 'error', + message: err.message || 'Unable to verify mobile number.', + }).code(status); + } +} + +module.exports = { getDoctor, loginDoctorByPhone }; diff --git a/backend/src/controllers/surveyController.js b/backend/src/controllers/surveyController.js new file mode 100644 index 0000000..736b82c --- /dev/null +++ b/backend/src/controllers/surveyController.js @@ -0,0 +1,208 @@ +const { pool } = require('../config/db'); +const { validateAnswers } = require('../questionnaire'); +const { resolveTgicsDoctor, PARTITION } = require('../services/rapport'); +const { checkRateLimit } = require('../middleware/rateLimit'); + +const MAX_PER_HOUR = Number(process.env.SURVEY_RATE_LIMIT_PER_HOUR || 60); + +async function createSurvey(request, h) { + const payload = request.payload || {}; + const userId = String(payload.user_id || '').trim(); + const contactId = String(payload.contact_id || '').trim(); + const answers = payload.answers; + + if (!userId || !contactId) { + return h.response({ + status: 'error', + message: 'user_id and contact_id are required.', + }).code(400); + } + + const ip = request.info.remoteAddress || 'unknown'; + const rateKey = `survey:${contactId}:${ip}`; + if (!checkRateLimit(rateKey, MAX_PER_HOUR)) { + return h.response({ + status: 'error', + message: 'Too many submissions. Please try again later.', + }).code(429); + } + + try { + const doctorResult = await resolveTgicsDoctor(userId, contactId); + if (!doctorResult.ok) { + return h.response({ status: 'error', message: doctorResult.message }).code(doctorResult.statusCode); + } + + const validation = validateAnswers(answers); + if (!validation.ok) { + return h.response({ status: 'error', message: validation.message }).code(400); + } + + const [result] = await pool.execute( + `INSERT INTO surveys (contact_id, user_id, \`partition\`, has_cough, answers_json) + VALUES (?, ?, ?, ?, ?)`, + [ + contactId, + userId, + PARTITION, + validation.hasCough ? 1 : 0, + JSON.stringify(answers), + ] + ); + + return h.response({ + status: 'success', + data: { id: result.insertId }, + message: 'Survey submitted successfully.', + }).code(201); + } catch (err) { + const status = err.statusCode || 500; + console.error('createSurvey error', err); + return h.response({ + status: 'error', + message: err.message || 'Unable to save survey.', + }).code(status); + } +} + +async function getCounts(request, h) { + const raw = String(request.query.contact_ids || '').trim(); + if (!raw) { + return h.response({ + status: 'success', + data: { counts: {}, total: 0 }, + }).code(200); + } + + const ids = [...new Set( + raw.split(',') + .map((s) => s.trim()) + .filter(Boolean) + )].slice(0, 500); + + if (ids.length === 0) { + return h.response({ + status: 'success', + data: { counts: {}, total: 0 }, + }).code(200); + } + + try { + const placeholders = ids.map(() => '?').join(','); + const [rows] = await pool.execute( + `SELECT contact_id, COUNT(*) AS cnt + FROM surveys + WHERE contact_id IN (${placeholders}) + GROUP BY contact_id`, + ids + ); + + const counts = {}; + for (const id of ids) { + counts[id] = 0; + } + let total = 0; + for (const row of rows) { + const n = Number(row.cnt) || 0; + counts[row.contact_id] = n; + total += n; + } + + return h.response({ + status: 'success', + data: { counts, total }, + }).code(200); + } catch (err) { + console.error('getCounts error', err); + return h.response({ + status: 'error', + message: 'Unable to load survey counts.', + }).code(500); + } +} + +async function getCount(request, h) { + const contactId = String(request.query.contact_id || '').trim(); + if (!contactId) { + return h.response({ + status: 'error', + message: 'contact_id is required.', + }).code(400); + } + + try { + const [rows] = await pool.execute( + 'SELECT COUNT(*) AS cnt FROM surveys WHERE contact_id = ?', + [contactId] + ); + const count = Number(rows[0]?.cnt || 0); + return h.response({ + status: 'success', + data: { contact_id: contactId, count }, + }).code(200); + } catch (err) { + console.error('getCount error', err); + return h.response({ + status: 'error', + message: 'Unable to load survey count.', + }).code(500); + } +} + +async function listSurveys(request, h) { + const contactId = String(request.query.contact_id || '').trim(); + if (!contactId) { + return h.response({ + status: 'error', + message: 'contact_id is required.', + }).code(400); + } + + const limit = Math.min(Math.max(Number(request.query.limit) || 100, 1), 500); + const offset = Math.max(Number(request.query.offset) || 0, 0); + + try { + const [rows] = await pool.execute( + `SELECT id, contact_id, user_id, has_cough, answers_json, created_at + FROM surveys + WHERE contact_id = ? + ORDER BY created_at DESC, id DESC + LIMIT ${limit} OFFSET ${offset}`, + [contactId] + ); + + const items = rows.map((row) => { + let answers = row.answers_json; + if (typeof answers === 'string') { + try { + answers = JSON.parse(answers); + } catch { + answers = {}; + } + } + answers = answers && typeof answers === 'object' ? answers : {}; + return { + id: row.id, + contact_id: row.contact_id, + user_id: row.user_id, + has_cough: Boolean(row.has_cough), + gender: answers.gender || '', + patient_age: answers.patient_age ?? '', + created_at: row.created_at, + }; + }); + + return h.response({ + status: 'success', + data: { items, contact_id: contactId }, + }).code(200); + } catch (err) { + console.error('listSurveys error', err); + return h.response({ + status: 'error', + message: 'Unable to load patients.', + }).code(500); + } +} + +module.exports = { createSurvey, getCounts, getCount, listSurveys }; diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js new file mode 100644 index 0000000..972e09c --- /dev/null +++ b/backend/src/middleware/auth.js @@ -0,0 +1,39 @@ +const jwt = require('jsonwebtoken'); + +const JWT_SECRET = process.env.JWT_SECRET || 'tgics-dev-secret-change-me'; + +function signUser(user) { + return jwt.sign( + { userId: user.id, email: user.email, name: user.name, role: user.role }, + JWT_SECRET, + { expiresIn: process.env.JWT_EXPIRES_IN || '7d' } + ); +} + +function getUserFromRequest(request) { + const header = request.headers.authorization || ''; + if (!header.startsWith('Bearer ')) return null; + const token = header.slice('Bearer '.length).trim(); + if (!token) return null; + try { + return jwt.verify(token, JWT_SECRET); + } catch { + return null; + } +} + +function requireAuth(request, h) { + const user = getUserFromRequest(request); + if (!user || !user.userId) { + return h.response({ status: 'error', message: 'Unauthorized' }).code(401).takeover(); + } + request.authUser = user; + return h.continue; +} + +module.exports = { + signUser, + getUserFromRequest, + requireAuth, + JWT_SECRET, +}; diff --git a/backend/src/middleware/rateLimit.js b/backend/src/middleware/rateLimit.js new file mode 100644 index 0000000..48df9e5 --- /dev/null +++ b/backend/src/middleware/rateLimit.js @@ -0,0 +1,24 @@ +const buckets = new Map(); + +function prune(key, windowMs) { + const now = Date.now(); + const entries = buckets.get(key) || []; + const fresh = entries.filter((ts) => now - ts < windowMs); + buckets.set(key, fresh); + return fresh; +} + +/** + * Simple in-memory rate limiter: maxHits per windowMs for a key. + */ +function checkRateLimit(key, maxHits, windowMs = 60 * 60 * 1000) { + const fresh = prune(key, windowMs); + if (fresh.length >= maxHits) { + return false; + } + fresh.push(Date.now()); + buckets.set(key, fresh); + return true; +} + +module.exports = { checkRateLimit }; diff --git a/backend/src/questionnaire.js b/backend/src/questionnaire.js new file mode 100644 index 0000000..0afa382 --- /dev/null +++ b/backend/src/questionnaire.js @@ -0,0 +1,332 @@ +/** + * TGICS patient questionnaire — stable codes + branching rules. + */ + +const opt = (value, label) => ({ value, label }); + +const QUESTIONS = { + gender: { + code: 'gender', + label: 'Gender', + type: 'single', + required: true, + options: [ + opt('male', 'Male'), + opt('female', 'Female'), + opt('prefer_not_to_say', 'Prefer not to say'), + ], + }, + patient_age: { + code: 'patient_age', + label: 'Age (in completed years)', + type: 'number', + required: true, + min: 0, + max: 120, + }, + exposure_history: { + code: 'exposure_history', + label: 'History of exposure?', + type: 'multi', + required: true, + options: [ + opt('never_smoker', 'Never smoker'), + opt('ex_smoker', 'Ex-smoker'), + opt('current_smoker', 'Current smoker'), + opt('pulmonary_tb_contact', 'Suffered or ever came in contact with a patient who had Pulmonary TB'), + opt('indoor_air_pollution', 'Current exposure to any sources of Indoor Air Pollution'), + ], + }, + occupation_exposure: { + code: 'occupation_exposure', + label: 'Work/occupation exposed to', + type: 'multi', + required: true, + options: [ + opt('smoke', 'Smoke'), + opt('fumes', 'Fumes'), + opt('dust', 'Dust'), + opt('gases', 'Gases'), + opt('none', 'None'), + ], + }, + family_history: { + code: 'family_history', + label: 'Family history', + type: 'multi', + required: true, + options: [ + opt('allergic_dermatitis_rhinitis_migraine', 'Allergic Dermatitis, Allergic Rhinitis, Migraine'), + opt('asthma', 'Asthma'), + opt('gerd', 'GERD (Gastroesophageal Reflux Disease)'), + opt('tb', 'TB'), + opt('rheumatic', 'Rheumatic diseases (arthritis, heart disease)'), + opt('cardiovascular', 'Cardiovascular diseases'), + opt('no_specific', 'No specific'), + ], + }, + past_disease_history: { + code: 'past_disease_history', + label: 'Past disease history of patient', + type: 'multi', + required: true, + options: [ + opt('allergic_dermatitis_rhinitis_migraine', 'Allergic Dermatitis, Allergic Rhinitis, Migraine'), + opt('asthma', 'Asthma'), + opt('copd', 'COPD'), + opt('tb', 'TB'), + opt('sinus_issues', 'Sinus issues (Post Nasal Drip)'), + opt('gerd', 'GERD (Gastroesophageal Reflux Disease)'), + opt('rheumatic', 'Rheumatic diseases (arthritis, heart disease)'), + opt('other_lung_disease', 'Any other lung disease'), + opt('no_history', 'No history of any disease'), + ], + }, + organ_system: { + code: 'organ_system', + label: 'What organ/system is involved for which the patient is visiting the doctor today?', + type: 'multi', + required: true, + options: [ + opt('skin', 'Skin'), + opt('brain_spinal', 'Brain & spinal cord'), + opt('ent', 'Ear/Nose/Throat'), + opt('heart', 'Heart disease'), + opt('lungs', 'Lungs & breathing tubes'), + opt('abdomen', 'Abdomen'), + opt('kidney', 'Kidney'), + opt('pregnancy_reproductive', 'Pregnancy & reproductive issues'), + opt('muscles_bones_joints', 'Muscles/Bones/Joints'), + ], + }, + ailment: { + code: 'ailment', + label: 'For which ailment is the patient visiting the Doctor today?', + type: 'multi', + required: true, + options: [ + opt('fever', 'Fever'), + opt('high_bp', 'High blood pressure'), + opt('diabetes', 'Diabetes'), + opt('anaemia', 'Anaemia'), + opt('stomach_upset', 'Stomach upset (vomiting / loose motions)'), + opt('cold_throat', 'Cold (runny nose) &/or throat irritation/pain'), + opt('thyroid', 'Thyroid'), + opt('rashes', 'Rashes (any where)'), + opt('swelling', 'Swelling on feet / body'), + opt('breathlessness', 'Breathlessness'), + opt('backache_weakness', 'Back ache / Weakness / Joint pain / Body ache'), + opt('foreign_body', 'Foreign body in eye/ear/nose/throat'), + opt('physical_injury', 'Physical injury'), + opt('others', 'Others (please specify briefly)'), + ], + otherField: 'ailment_other', + }, + has_cough: { + code: 'has_cough', + label: 'Does the patient complain of cough?', + type: 'single', + required: true, + options: [opt('yes', 'Yes'), opt('no', 'No')], + }, + cough_duration: { + code: 'cough_duration', + label: 'What is the duration of cough?', + type: 'single', + required: true, + options: [ + opt('lt_7_days', 'Less than 7 days'), + opt('7_days_to_3_weeks', '7 days to <3 weeks'), + opt('3_weeks_to_2_months', '3 weeks to 2 months'), + opt('gt_2_months', 'More than 2 months'), + ], + }, + cough_type: { + code: 'cough_type', + label: 'Is the cough dry or wet?', + type: 'single', + required: true, + options: [ + opt('dry', 'Dry'), + opt('wet', 'Wet (with sputum/mucus/phlegm)'), + ], + }, + sputum_quantity: { + code: 'sputum_quantity', + label: 'How much is the quantity of sputum/mucus/phlegm?', + type: 'single', + required: true, + options: [ + opt('scanty', 'Scanty'), + opt('moderate', 'Moderate'), + opt('excess', 'Excess'), + opt('sometimes_excess_sometimes_less', 'Sometimes excess and sometimes less'), + opt('more_early_morning', 'More in early morning'), + ], + }, + sputum_colour: { + code: 'sputum_colour', + label: 'What is the colour of the sputum/mucus/phlegm?', + type: 'single', + required: true, + options: [ + opt('white', 'White'), + opt('yellow', 'Yellow'), + opt('green', 'Green'), + opt('brownish', 'Brownish'), + opt('pink', 'Pink'), + opt('blood_tinged', 'Blood tinged'), + ], + }, + cough_associated: { + code: 'cough_associated', + label: 'Is cough associated with?', + type: 'multi', + required: true, + options: [ + opt('fever', 'Fever'), + opt('weight_loss', 'Weight loss'), + opt('loss_of_appetite', 'Loss of appetite'), + opt('evening_rise_fever', 'Evening rise of fever'), + opt('difficulty_breathing', 'Difficulty in breathing'), + opt('wheeze', 'Whistling sound from chest/ wheeze'), + opt('chest_pain', 'Chest pain'), + opt('hoarseness', 'Hoarseness of voice'), + opt('sinusitis', 'Sinusitis (Post Nasal Drip)'), + opt('acid_reflux', 'Acid Reflux/Regurgitation'), + opt('night_sweats', 'Night sweats'), + opt('others', 'Others (please specify)'), + opt('none', 'None of the above'), + ], + otherField: 'cough_associated_other', + }, + cough_diagnosis: { + code: 'cough_diagnosis', + label: 'What is the diagnosis for cough?', + type: 'single', + required: true, + options: [ + opt('common_cold', 'Common cold'), + opt('sinusitis', 'Sinusitis'), + opt('allergic_cough', 'Allergic Cough'), + opt('allergic_rhinitis', 'Allergic Rhinitis'), + opt('asthma', 'Asthma'), + opt('drug_induced', 'Drug induced cough'), + opt('acidity_gerd', 'Acidity induced cough (GERD)'), + opt('post_nasal_drip', 'Post Nasal Drip (Upper Airway Cough Syndrome)'), + opt('tb', 'TB'), + opt('copd', 'COPD'), + opt('others', 'Others (please specify)'), + opt('not_sure', 'Not sure of diagnosis'), + ], + otherField: 'cough_diagnosis_other', + }, +}; + +function optionValues(question) { + return new Set(question.options.map((o) => o.value)); +} + +function validateAnswers(answers) { + if (!answers || typeof answers !== 'object') { + return { ok: false, message: 'Answers are required.' }; + } + + const requireSingle = (code) => { + const q = QUESTIONS[code]; + const val = answers[code]; + if (val === undefined || val === null || val === '') { + return `${q.label} is required.`; + } + if (!optionValues(q).has(String(val))) { + return `Invalid value for ${q.label}.`; + } + return null; + }; + + const requireMulti = (code) => { + const q = QUESTIONS[code]; + const val = answers[code]; + if (!Array.isArray(val) || val.length === 0) { + return `${q.label} is required.`; + } + const allowed = optionValues(q); + for (const v of val) { + if (!allowed.has(String(v))) { + return `Invalid value for ${q.label}.`; + } + } + return null; + }; + + const requireOther = (code) => { + const q = QUESTIONS[code]; + if (!q.otherField) return null; + const val = answers[code]; + const selected = Array.isArray(val) ? val : [val]; + if (selected.includes('others')) { + const other = String(answers[q.otherField] || '').trim(); + if (!other) { + return `Please specify details for ${q.label}.`; + } + } + return null; + }; + + let err = + requireSingle('gender') || + null; + if (err) return { ok: false, message: err }; + + const age = Number(answers.patient_age); + if (!Number.isFinite(age) || age < 0 || age > 120) { + return { ok: false, message: 'Valid patient age is required (0–120).' }; + } + + for (const code of [ + 'exposure_history', + 'occupation_exposure', + 'family_history', + 'past_disease_history', + 'organ_system', + 'ailment', + ]) { + err = requireMulti(code) || requireOther(code); + if (err) return { ok: false, message: err }; + } + + err = requireSingle('has_cough'); + if (err) return { ok: false, message: err }; + + const hasCough = String(answers.has_cough) === 'yes'; + if (!hasCough) { + return { ok: true, hasCough: false }; + } + + err = + requireSingle('cough_duration') || + requireSingle('cough_type'); + if (err) return { ok: false, message: err }; + + const isWet = String(answers.cough_type) === 'wet'; + if (isWet) { + err = + requireSingle('sputum_quantity') || + requireSingle('sputum_colour'); + if (err) return { ok: false, message: err }; + } + + err = + requireMulti('cough_associated') || + requireOther('cough_associated') || + requireSingle('cough_diagnosis') || + requireOther('cough_diagnosis'); + if (err) return { ok: false, message: err }; + + return { ok: true, hasCough: true }; +} + +module.exports = { + QUESTIONS, + validateAnswers, +}; diff --git a/backend/src/routes/authRoutes.js b/backend/src/routes/authRoutes.js new file mode 100644 index 0000000..9716adb --- /dev/null +++ b/backend/src/routes/authRoutes.js @@ -0,0 +1,18 @@ +const { login, me } = require('../controllers/authController'); +const { requireAuth } = require('../middleware/auth'); + +module.exports = [ + { + method: 'POST', + path: '/api/auth/login', + handler: login, + }, + { + method: 'GET', + path: '/api/auth/me', + options: { + pre: [{ method: requireAuth }], + }, + handler: me, + }, +]; diff --git a/backend/src/routes/dashboardRoutes.js b/backend/src/routes/dashboardRoutes.js new file mode 100644 index 0000000..728ef4f --- /dev/null +++ b/backend/src/routes/dashboardRoutes.js @@ -0,0 +1,21 @@ +const { listSurveys, surveyStats } = require('../controllers/dashboardController'); +const { requireAuth } = require('../middleware/auth'); + +module.exports = [ + { + method: 'GET', + path: '/api/dashboard/stats', + options: { + pre: [{ method: requireAuth }], + }, + handler: surveyStats, + }, + { + method: 'GET', + path: '/api/dashboard/surveys', + options: { + pre: [{ method: requireAuth }], + }, + handler: listSurveys, + }, +]; diff --git a/backend/src/routes/doctorRoutes.js b/backend/src/routes/doctorRoutes.js new file mode 100644 index 0000000..74fad59 --- /dev/null +++ b/backend/src/routes/doctorRoutes.js @@ -0,0 +1,20 @@ +const { getDoctor, loginDoctorByPhone } = require('../controllers/doctorController'); + +module.exports = [ + { + method: 'GET', + path: '/api/doctors/{userId}/{contactId}', + options: { + description: 'Resolve TGICS enrolled doctor via Rapport contact (legacy deep link)', + }, + handler: getDoctor, + }, + { + method: 'POST', + path: '/api/doctors/{userId}/login', + options: { + description: 'Resolve TGICS enrolled doctor by WhatsApp phone for a common MR link', + }, + handler: loginDoctorByPhone, + }, +]; diff --git a/backend/src/routes/surveyRoutes.js b/backend/src/routes/surveyRoutes.js new file mode 100644 index 0000000..f3c7889 --- /dev/null +++ b/backend/src/routes/surveyRoutes.js @@ -0,0 +1,36 @@ +const { createSurvey, getCounts, getCount, listSurveys } = require('../controllers/surveyController'); + +module.exports = [ + { + method: 'POST', + path: '/api/surveys', + options: { + description: 'Submit a patient survey for a TGICS doctor', + }, + handler: createSurvey, + }, + { + method: 'GET', + path: '/api/surveys', + options: { + description: 'List patient surveys for a doctor contact_id', + }, + handler: listSurveys, + }, + { + method: 'GET', + path: '/api/surveys/counts', + options: { + description: 'Batch survey counts by contact_ids (comma-separated)', + }, + handler: getCounts, + }, + { + method: 'GET', + path: '/api/surveys/count', + options: { + description: 'Single contact survey count', + }, + handler: getCount, + }, +]; diff --git a/backend/src/seed.js b/backend/src/seed.js new file mode 100644 index 0000000..5d23589 --- /dev/null +++ b/backend/src/seed.js @@ -0,0 +1,32 @@ +const bcrypt = require('bcrypt'); +const { pool } = require('./config/db'); + +/** + * Ensure the first dashboard admin exists. + */ +async function seedUsers() { + const email = 'ravi@stratawiz.com'; + const password = 'qwerty'; + const name = 'Ravi'; + const role = 'admin'; + + const [existing] = await pool.execute( + 'SELECT id FROM users WHERE email = ? LIMIT 1', + [email] + ); + + if (existing.length) { + console.log('Seed user already exists:', email); + return; + } + + const passwordHash = await bcrypt.hash(password, 10); + await pool.execute( + `INSERT INTO users (name, email, password_hash, role, status) + VALUES (?, ?, ?, ?, 'active')`, + [name, email, passwordHash, role] + ); + console.log('Seeded dashboard user:', email); +} + +module.exports = { seedUsers }; diff --git a/backend/src/server.js b/backend/src/server.js new file mode 100644 index 0000000..52af403 --- /dev/null +++ b/backend/src/server.js @@ -0,0 +1,57 @@ +const Hapi = require('@hapi/hapi'); +require('dotenv').config(); +const { initializeDb } = require('./config/db'); +const { seedUsers } = require('./seed'); + +const init = async () => { + const origins = [ + ...new Set( + (process.env.FRONTEND_ORIGIN || 'http://localhost:5173') + .split(',') + .map((o) => o.trim()) + .filter(Boolean) + .concat([ + 'http://localhost:5173', + 'http://localhost:5174', + 'http://127.0.0.1:5173', + 'http://127.0.0.1:5174', + ]) + ), + ]; + + const server = Hapi.server({ + port: Number(process.env.PORT || 3050), + host: process.env.HOST || '0.0.0.0', + routes: { + cors: { + origin: origins, + headers: ['Accept', 'Authorization', 'Content-Type', 'If-None-Match'], + additionalHeaders: ['X-Requested-With'], + }, + }, + }); + + server.route([ + { + method: 'GET', + path: '/health', + handler: () => ({ status: 'ok', service: 'tgics' }), + }, + ...require('./routes/authRoutes'), + ...require('./routes/dashboardRoutes'), + ...require('./routes/doctorRoutes'), + ...require('./routes/surveyRoutes'), + ]); + + await initializeDb(); + await seedUsers(); + await server.start(); + console.log('TGICS API running on %s', server.info.uri); +}; + +process.on('unhandledRejection', (err) => { + console.error(err); + process.exit(1); +}); + +init(); diff --git a/backend/src/services/rapport.js b/backend/src/services/rapport.js new file mode 100644 index 0000000..1f1ddfd --- /dev/null +++ b/backend/src/services/rapport.js @@ -0,0 +1,183 @@ +require('dotenv').config(); + +const API_BASE = (process.env.RAPPORT_API_BASE || 'https://rapportapi.click/').replace(/\/?$/, '/'); +const ACCESS_TOKEN = process.env.RAPPORT_ACCESS_TOKEN || ''; +const PARTITION = process.env.RAPPORT_PARTITION || 'rapport-lifemankind'; + +/** + * Fetch a contact from Rapport contact/get_one. + * Returns a normalized doctor object or null. + */ +async function getContactById(contactId) { + if (!contactId) return null; + if (!ACCESS_TOKEN) { + const err = new Error('RAPPORT_ACCESS_TOKEN is not configured'); + err.statusCode = 503; + throw err; + } + + const url = new URL('contact/contact/get_one', API_BASE); + url.searchParams.set('id', contactId); + url.searchParams.set('application', 'rapport'); + + const response = await fetch(url.toString(), { + method: 'GET', + headers: { + Authorization: ACCESS_TOKEN, + Accept: 'application/json', + }, + }); + + const body = await response.json().catch(() => ({})); + if (!response.ok) { + const err = new Error(body.message || `Rapport contact lookup failed (${response.status})`); + err.statusCode = response.status === 401 ? 503 : 502; + throw err; + } + + const item = body?.data?.item || body?.item || null; + if (!item || !item.id) { + return null; + } + + // Optional partition check when present on the contact + if (item.partition && item.partition !== PARTITION) { + return null; + } + + return item; +} + +function isTgicsEnrolled(contact) { + const flag = contact?.tgics_enrolled_doctor; + if (flag === undefined || flag === null) return false; + return String(flag).toLowerCase() === 'yes' || flag === true || flag === 1 || flag === '1'; +} + +function toDoctorPayload(contact, userId) { + return { + contact_id: contact.id, + user_id: userId || null, + name: contact.name || '', + wfsa_code: contact.msl_code || '', + phone: contact.phone || '', + email: contact.email || '', + age: contact.age || '', + years_of_practice: contact.years_of_practice || '', + type_of_practice: contact.type_of_practice || '', + city: contact.city || contact.city_town || '', + state: contact.state || '', + qualification: contact.qualification || '', + tgics_enrolled_doctor: contact.tgics_enrolled_doctor || '', + }; +} + +async function resolveTgicsDoctor(userId, contactId) { + const contact = await getContactById(contactId); + if (!contact) { + return { ok: false, statusCode: 404, message: 'Doctor not found.' }; + } + if (!isTgicsEnrolled(contact)) { + return { + ok: false, + statusCode: 403, + message: 'Doctor is not enrolled for TGICS.', + }; + } + return { ok: true, doctor: toDoctorPayload(contact, userId), contact }; +} + +function normalizePhone(phone) { + const digits = String(phone || '').replace(/\D+/g, ''); + if (digits.length >= 10) { + return digits.slice(-10); + } + return digits; +} + +async function searchContactsByKeyword(keyword) { + if (!ACCESS_TOKEN) { + const err = new Error('RAPPORT_ACCESS_TOKEN is not configured'); + err.statusCode = 503; + throw err; + } + + const url = new URL('contact/contact/get_all', API_BASE); + url.searchParams.set('keyword', keyword); + url.searchParams.set('start', '0'); + url.searchParams.set('length', '100'); + url.searchParams.set('search', JSON.stringify({})); + + const response = await fetch(url.toString(), { + method: 'GET', + headers: { + Authorization: ACCESS_TOKEN, + Accept: 'application/json', + }, + }); + + const body = await response.json().catch(() => ({})); + if (!response.ok) { + const err = new Error(body.message || `Rapport contact search failed (${response.status})`); + err.statusCode = response.status === 401 ? 503 : 502; + throw err; + } + + return Array.isArray(body?.data?.items) ? body.data.items : []; +} + +/** + * Resolve TGICS doctor by MR userId + WhatsApp phone (last 10 digits). + * Prefers contacts owned by that user_id. + */ +async function resolveTgicsDoctorByPhone(userId, phone) { + const needle = normalizePhone(phone); + if (needle.length < 10) { + return { + ok: false, + statusCode: 400, + message: 'Please enter a valid 10-digit mobile number.', + }; + } + + const items = await searchContactsByKeyword(needle); + const matches = items.filter((contact) => { + if (!contact || !contact.id) return false; + if (!isTgicsEnrolled(contact)) return false; + if (normalizePhone(contact.phone) !== needle) return false; + if (contact.partition && contact.partition !== PARTITION) return false; + return true; + }); + + if (matches.length === 0) { + return { + ok: false, + statusCode: 404, + message: 'No enrolled doctor found for this mobile number.', + }; + } + + const forUser = matches.filter((c) => String(c.user_id || '') === String(userId)); + const unscoped = matches.filter((c) => !c.user_id); + const contact = forUser[0] || unscoped[0]; + + if (!contact) { + return { + ok: false, + statusCode: 404, + message: 'No enrolled doctor found for this mobile number under this link.', + }; + } + + return { ok: true, doctor: toDoctorPayload(contact, userId), contact }; +} + +module.exports = { + getContactById, + isTgicsEnrolled, + toDoctorPayload, + resolveTgicsDoctor, + resolveTgicsDoctorByPhone, + normalizePhone, + PARTITION, +}; diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..a8c2689 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1 @@ +VITE_API_BASE=http://localhost:3050 diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..94362eb --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +.env +.env.local +*.log +.DS_Store diff --git a/frontend/.oxlintrc.json b/frontend/.oxlintrc.json new file mode 100644 index 0000000..1255078 --- /dev/null +++ b/frontend/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "oxc"], + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..d937833 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,16 @@ +# React + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the Oxlint configuration + +If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and Oxlint's TypeScript related rules in your project. diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..b48d617 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + TGICS — The Great Indian Cough Study + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..0168073 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1374 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "react": "^19.3.1", + "react-dom": "^19.3.1", + "react-router-dom": "^7.18.4" + }, + "devDependencies": { + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "@vitejs/plugin-react": "^6.1.2", + "oxlint": "^1.88.0", + "vite": "^8.3.4" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.155.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz", + "integrity": "sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.89.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.89.0.tgz", + "integrity": "sha512-Pflpba5F3gRAQ12sWglRxvWTHEdAusNDvmi4Y5N1T5SY1AGkM7AixOnET8qkrnLJ/F32qAlNokO6eCknT8tmnw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.89.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.89.0.tgz", + "integrity": "sha512-dYmyD1x7LaPlb79vZWUcpTShk8dI9z1I+IubtqrBxWNJaDhQdPHwcsvg7q5StTU4xWyd9AwPfLaTYFZY8NZgtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.89.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.89.0.tgz", + "integrity": "sha512-Z/oxOPiIqiS7fItRdxaaOEC+6GD3OpGi0IxoR0dMefzQHolsKLzScw2DNcu/5gUPok3tv+G9mslvYB5bo3i0qg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.89.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.89.0.tgz", + "integrity": "sha512-eAjTkHSjAxrdCPJxO6y5sPQL5pYLKb2kvxZz9UU7tXnOZk9I/HLtkvx9S7GHY883k+AmL/eC7mqFt+w7WCwk0w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.89.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.89.0.tgz", + "integrity": "sha512-0lNCA9Tl1n1FKpqy2/9ckp0kIuSrtJ/l9bMRgaEhOtM7TsOHshFuwOcvW8oA9teXLMOJT61LjEKWiaHDFGxF/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.89.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.89.0.tgz", + "integrity": "sha512-OTFEvi9W+JczaZD1Caj17SGCoW8H7TE+fvx0D7ATEWa1Lohn91UMxFQ6r145SiGy/yCxeIwptMys/RAM7gSVSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.89.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.89.0.tgz", + "integrity": "sha512-fRxgNHvBLswj6MrhW2uBq5UDhPRQlCbHeq/Nf8yuG9zOxhDsXqX/mi2PX1axgsQxMm88el7HLiJAzglAnflYxw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.89.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.89.0.tgz", + "integrity": "sha512-v9ENzHvwrQoYSWTtviH1pOYc7R9UovDxVofnck50cNmPihRRTcrg5X0UlRbLOZ7IVtxPOlh2hb7kDO+FpE8SfQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.89.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.89.0.tgz", + "integrity": "sha512-vfffe8mQ484AzVadlUOFpgL+JDwhvs9hrzxTgQP1FVfi4ZEUUYeffXlzC9dFV7Dgnu5s/KrNKvfmG6HxBd/ISQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.89.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.89.0.tgz", + "integrity": "sha512-Buonlp4hVS7RGNP5M7bPpGNQrenyzA2C64JcO/oz9C1c6dGOXJ7VEAnaiAMY7voou0JYyCJdvDVlFQc5DZX3DQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.89.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.89.0.tgz", + "integrity": "sha512-1eVwJJrJ3lYIPT+cm7BcBBZZXY6IQZP/oyonaruTor/MrVyWOpvnwt1oD5CLdsHg5aVBIxXodQSvt+1gK6ck5w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.89.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.89.0.tgz", + "integrity": "sha512-fRQQsKUGShdEL5Fl9o9uhj3grkZbeXKLv0icQ5tspUCNYSDy8wzzEcP1WC2bKdTQ0rSszbKlO3iiHORxV22DLg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.89.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.89.0.tgz", + "integrity": "sha512-dEtYjIy6+fHcjKklsmtmMkZRhwHr2UbzjmpvuPtQYSeN9awQ2hx5JLms5Wvri+8P/NZtD8v5hfgHALUql+/Ycg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.89.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.89.0.tgz", + "integrity": "sha512-hqjwIj5KOYM8Wjef/p7IruNi1iByLhSzbMBk62y22P/E8q7Jj0Pp+DeUTKTRYyZYCDtU1wEGlnWOnttHbyNIww==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.89.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.89.0.tgz", + "integrity": "sha512-iAQN3e6o9UgUCcdGwqRx/jYcKbG3HK7ur3rH6lTqVcQeNGZ85AeWIg6cgn/edwFQ0ofEZZItMwkzyCVTahnvEA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.89.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.89.0.tgz", + "integrity": "sha512-MNxjEByz3N/7Q4qFQuPWvc14TpN9LoD3pECEDsJTZbP/wyB7U+hduUXEl7QAbbgVezFwsb9sWtNUO6XVBndOhg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.89.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.89.0.tgz", + "integrity": "sha512-xxe+i9wfuFso/VnoL4mfj0UlzE+cfCFiUB06vkXCMyQdgVVmItF5FfAF4tATAktSU6uHruWR+FjqvAT9cHZZzg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.89.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.89.0.tgz", + "integrity": "sha512-saFy07cO94E664glwgLmKLI40HgTL1PY86y4T6+ud4niX4H0Kdqjcmqmjps7mQNdBytV5guGbXDdyQtRfeLbLA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.89.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.89.0.tgz", + "integrity": "sha512-hLFJ/o0f/dTJh/EW0vmcbREYSQO9tYgfiAAlsOkiLFiJuRRWvYtFELUXN5o2ijpSo/tlO1n+YlWN58tZwPG18A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.15.tgz", + "integrity": "sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.15.tgz", + "integrity": "sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.15.tgz", + "integrity": "sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.15.tgz", + "integrity": "sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.15.tgz", + "integrity": "sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.15.tgz", + "integrity": "sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.15.tgz", + "integrity": "sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.15.tgz", + "integrity": "sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.15.tgz", + "integrity": "sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.15.tgz", + "integrity": "sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.15.tgz", + "integrity": "sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.15.tgz", + "integrity": "sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.15.tgz", + "integrity": "sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.15.tgz", + "integrity": "sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.15.tgz", + "integrity": "sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.2.tgz", + "integrity": "sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.152.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/oxlint": { + "version": "1.89.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.89.0.tgz", + "integrity": "sha512-a25hkfo61okGPvJz432fRIPoxlKtNnOVy9w4rzrmGgNm4iVTPwpSQlmdqNWnp6p9QyyPVfAZRgnf5XvSBZFk/A==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.89.0", + "@oxlint/binding-android-arm64": "1.89.0", + "@oxlint/binding-darwin-arm64": "1.89.0", + "@oxlint/binding-darwin-x64": "1.89.0", + "@oxlint/binding-freebsd-x64": "1.89.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.89.0", + "@oxlint/binding-linux-arm-musleabihf": "1.89.0", + "@oxlint/binding-linux-arm64-gnu": "1.89.0", + "@oxlint/binding-linux-arm64-musl": "1.89.0", + "@oxlint/binding-linux-ppc64-gnu": "1.89.0", + "@oxlint/binding-linux-riscv64-gnu": "1.89.0", + "@oxlint/binding-linux-riscv64-musl": "1.89.0", + "@oxlint/binding-linux-s390x-gnu": "1.89.0", + "@oxlint/binding-linux-x64-gnu": "1.89.0", + "@oxlint/binding-linux-x64-musl": "1.89.0", + "@oxlint/binding-openharmony-arm64": "1.89.0", + "@oxlint/binding-win32-arm64-msvc": "1.89.0", + "@oxlint/binding-win32-ia32-msvc": "1.89.0", + "@oxlint/binding-win32-x64-msvc": "1.89.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=7.0.2003", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", + "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.1.tgz", + "integrity": "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.1.tgz", + "integrity": "sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.1" + } + }, + "node_modules/react-router": { + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz", + "integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.4.tgz", + "integrity": "sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.4" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/rolldown": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.15.tgz", + "integrity": "sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.155.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.15", + "@rolldown/binding-android-arm64": "1.2.15", + "@rolldown/binding-darwin-arm64": "1.2.15", + "@rolldown/binding-darwin-x64": "1.2.15", + "@rolldown/binding-freebsd-x64": "1.2.15", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.15", + "@rolldown/binding-linux-arm64-gnu": "1.2.15", + "@rolldown/binding-linux-arm64-musl": "1.2.15", + "@rolldown/binding-linux-ppc64-gnu": "1.2.15", + "@rolldown/binding-linux-s390x-gnu": "1.2.15", + "@rolldown/binding-linux-x64-gnu": "1.2.15", + "@rolldown/binding-linux-x64-musl": "1.2.15", + "@rolldown/binding-openharmony-arm64": "1.2.15", + "@rolldown/binding-win32-arm64-msvc": "1.2.15", + "@rolldown/binding-win32-x64-msvc": "1.2.15" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/vite": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz", + "integrity": "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.30", + "rolldown": "~1.2.14", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1 || ^0.8.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..498714a --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,24 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "oxlint", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.3.1", + "react-dom": "^19.3.1", + "react-router-dom": "^7.18.4" + }, + "devDependencies": { + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "@vitejs/plugin-react": "^6.1.2", + "oxlint": "^1.88.0", + "vite": "^8.3.4" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/frontend/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000..e8785cd --- /dev/null +++ b/frontend/src/App.css @@ -0,0 +1,306 @@ +.page { + max-width: 640px; + margin: 0 auto; + padding: 1rem 1rem 2.5rem; +} + +.page-wide { + max-width: 960px; +} + +.dash-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; +} + +.login-form .btn { + width: 100%; +} + +.list-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 0.75rem; + margin-bottom: 1rem; +} + +.list-title { + margin: 0 0 0.2rem; + font-size: 1.15rem; +} + +.center-empty { + text-align: center; + padding: 1.5rem 0.5rem; +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem; + margin-bottom: 0.85rem; +} + +@media (min-width: 720px) { + .stats-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } +} + +.stat-card { + background: var(--card); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 0.9rem 1rem; +} + +.stat-card small { + display: block; + color: var(--muted); + margin-bottom: 0.25rem; +} + +.stat-card strong { + font-size: 1.4rem; +} + +.table-wrap { + overflow-x: auto; +} + +.data-table { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; +} + +.data-table th, +.data-table td { + text-align: left; + padding: 0.55rem 0.4rem; + border-bottom: 1px solid var(--line); + vertical-align: top; +} + +.data-table th { + color: var(--muted); + font-weight: 600; +} + +.mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.8rem; + word-break: break-all; +} + +.brand-bar { + padding: 1.25rem 0.25rem 1rem; +} + +.brand-bar h1 { + margin: 0; + font-size: 1.75rem; + letter-spacing: 0.04em; + color: var(--brand-dark); +} + +.brand-bar p { + margin: 0.2rem 0 0; + color: var(--muted); + font-size: 0.95rem; +} + +.doctor-card { + background: linear-gradient(135deg, #1f6b4a, #2f8f64); + color: #fff; + border-radius: var(--radius); + padding: 1rem 1.1rem; + margin-bottom: 0.85rem; + box-shadow: 0 10px 24px rgba(31, 107, 74, 0.22); +} + +.doctor-name { + font-size: 1.15rem; + font-weight: 650; +} + +.doctor-meta { + display: flex; + flex-wrap: wrap; + gap: 0.65rem; + margin-top: 0.35rem; + font-size: 0.9rem; + opacity: 0.92; +} + +.card { + background: var(--card); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 1.1rem; + box-shadow: 0 8px 24px rgba(28, 42, 34, 0.05); +} + +.card.center { + text-align: center; + padding: 2rem 1rem; +} + +.card h2 { + margin-top: 0; +} + +.muted { + color: var(--muted); +} + +.small { + font-size: 0.9rem; +} + +.code-block { + display: block; + background: var(--bg-accent); + border-radius: 8px; + padding: 0.75rem; + margin: 0.75rem 0; + font-size: 0.85rem; + overflow-x: auto; +} + +.progress-wrap { + height: 6px; + background: #e8eee9; + border-radius: 999px; + overflow: hidden; + margin-bottom: 0.75rem; +} + +.progress-bar { + height: 100%; + background: var(--brand); + transition: width 0.2s ease; +} + +.step-label { + margin: 0 0 1rem; + font-size: 0.92rem; + color: var(--muted); + font-weight: 600; +} + +.field { + border: 0; + margin: 0 0 1.1rem; + padding: 0; +} + +.field-label { + display: block; + font-weight: 600; + margin-bottom: 0.45rem; + font-size: 0.98rem; +} + +.req { + color: var(--danger); +} + +.field-input { + width: 100%; + border: 1px solid var(--line); + border-radius: 10px; + padding: 0.7rem 0.8rem; + background: #fff; +} + +.field-input:focus { + outline: 2px solid rgba(31, 107, 74, 0.25); + border-color: var(--brand); +} + +.option-list { + display: grid; + gap: 0.45rem; +} + +.option-row { + display: flex; + gap: 0.6rem; + align-items: flex-start; + padding: 0.55rem 0.65rem; + border: 1px solid var(--line); + border-radius: 10px; + background: #fbfcfb; + cursor: pointer; +} + +.option-row input { + margin-top: 0.2rem; +} + +.mt { + margin-top: 0.55rem; +} + +.actions { + display: flex; + justify-content: space-between; + gap: 0.75rem; + margin-top: 1.25rem; +} + +.btn { + border: 0; + border-radius: 999px; + padding: 0.7rem 1.2rem; + cursor: pointer; + font-weight: 600; +} + +.btn:disabled { + opacity: 0.65; + cursor: not-allowed; +} + +.btn.primary { + background: var(--brand); + color: #fff; +} + +.btn.primary:hover:not(:disabled) { + background: var(--brand-dark); +} + +.btn.ghost { + background: transparent; + border: 1px solid var(--line); + color: var(--ink); +} + +.error { + color: var(--danger); + background: #fef3f2; + border: 1px solid #fecdca; + border-radius: 10px; + padding: 0.65rem 0.75rem; + margin: 0.5rem 0 0; +} + +.text-link { + color: var(--brand); + background: none; + border: 0; + padding: 0; + cursor: pointer; + font: inherit; + text-decoration: underline; +} + +.doctor-meta .text-link { + color: #fff; + opacity: 0.95; +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..a38fc7b --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,21 @@ +import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'; +import HomePage from './pages/HomePage'; +import SurveyPage from './pages/SurveyPage'; +import LoginPage from './pages/LoginPage'; +import DashboardPage from './pages/DashboardPage'; +import './App.css'; + +export default function App() { + return ( + + + } /> + } /> + } /> + } /> + } /> + } /> + + + ); +} diff --git a/frontend/src/api.js b/frontend/src/api.js new file mode 100644 index 0000000..c1815f6 --- /dev/null +++ b/frontend/src/api.js @@ -0,0 +1,83 @@ +import { getToken } from './auth'; + +const API_BASE = (import.meta.env.VITE_API_BASE || '').replace(/\/$/, ''); + +async function request(path, options = {}) { + const headers = { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...(options.headers || {}), + }; + const token = getToken(); + if (token && !headers.Authorization) { + headers.Authorization = `Bearer ${token}`; + } + + let res; + try { + res = await fetch(`${API_BASE}${path}`, { + ...options, + headers, + }); + } catch (networkErr) { + const err = new Error( + `Cannot reach TGICS API at ${API_BASE || '(missing VITE_API_BASE)'}. Is the backend running, and is CORS allowing this origin?` + ); + err.cause = networkErr; + throw err; + } + const data = await res.json().catch(() => ({})); + if (!res.ok) { + const err = new Error(data.message || `Request failed (${res.status})`); + err.status = res.status; + err.data = data; + throw err; + } + return data; +} + +export function fetchDoctor(userId, contactId) { + return request(`/api/doctors/${encodeURIComponent(userId)}/${encodeURIComponent(contactId)}`); +} + +export function loginDoctorByPhone(userId, phone) { + return request(`/api/doctors/${encodeURIComponent(userId)}/login`, { + method: 'POST', + body: JSON.stringify({ phone }), + }); +} + +export function submitSurvey(payload) { + return request('/api/surveys', { + method: 'POST', + body: JSON.stringify(payload), + }); +} + +export function fetchPatients(contactId, limit = 100, offset = 0) { + const qs = new URLSearchParams({ + contact_id: contactId, + limit: String(limit), + offset: String(offset), + }); + return request(`/api/surveys?${qs.toString()}`); +} + +export function login(email, password) { + return request('/api/auth/login', { + method: 'POST', + body: JSON.stringify({ email, password }), + }); +} + +export function fetchMe() { + return request('/api/auth/me'); +} + +export function fetchDashboardStats() { + return request('/api/dashboard/stats'); +} + +export function fetchDashboardSurveys(limit = 50, offset = 0) { + return request(`/api/dashboard/surveys?limit=${limit}&offset=${offset}`); +} diff --git a/frontend/src/assets/hero.png b/frontend/src/assets/hero.png new file mode 100644 index 0000000000000000000000000000000000000000..02251f4b956c55af2d76fd0788124d7eee2b45eb GIT binary patch literal 13057 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 P00000NkvXXu0mjft=yBf literal 0 HcmV?d00001 diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/frontend/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/vite.svg b/frontend/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/frontend/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/frontend/src/auth.js b/frontend/src/auth.js new file mode 100644 index 0000000..bb32950 --- /dev/null +++ b/frontend/src/auth.js @@ -0,0 +1,28 @@ +const TOKEN_KEY = 'tgics_token'; +const USER_KEY = 'tgics_user'; + +export function getToken() { + return localStorage.getItem(TOKEN_KEY) || ''; +} + +export function getUser() { + try { + return JSON.parse(localStorage.getItem(USER_KEY) || 'null'); + } catch { + return null; + } +} + +export function setSession(token, user) { + localStorage.setItem(TOKEN_KEY, token); + localStorage.setItem(USER_KEY, JSON.stringify(user)); +} + +export function clearSession() { + localStorage.removeItem(TOKEN_KEY); + localStorage.removeItem(USER_KEY); +} + +export function isLoggedIn() { + return Boolean(getToken()); +} diff --git a/frontend/src/components/QuestionField.jsx b/frontend/src/components/QuestionField.jsx new file mode 100644 index 0000000..ee31855 --- /dev/null +++ b/frontend/src/components/QuestionField.jsx @@ -0,0 +1,96 @@ +export default function QuestionField({ question, value, otherValue, onChange, onOtherChange }) { + if (!question) return null; + + if (question.type === 'number') { + return ( +
+ + onChange(e.target.value === '' ? '' : Number(e.target.value))} + /> +
+ ); + } + + if (question.type === 'single') { + return ( +
+ + {question.label} * + +
+ {question.options.map((opt) => ( + + ))} +
+ {question.otherField && value === 'others' && ( + onOtherChange(e.target.value)} + /> + )} +
+ ); + } + + const selected = Array.isArray(value) ? value : []; + + return ( +
+ + {question.label} * + +
+ {question.options.map((opt) => { + const checked = selected.includes(opt.value); + return ( + + ); + })} +
+ {question.otherField && selected.includes('others') && ( + onOtherChange(e.target.value)} + /> + )} +
+ ); +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..8cf04e3 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,40 @@ +:root { + color-scheme: light; + --bg: #f3f1ec; + --bg-accent: #e7efe8; + --ink: #1c2a22; + --muted: #5b6b62; + --card: #ffffff; + --line: #d5ddd7; + --brand: #1f6b4a; + --brand-dark: #155239; + --danger: #b42318; + --radius: 14px; + font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif; + line-height: 1.45; + font-weight: 400; + color: var(--ink); + background: + radial-gradient(1200px 500px at 10% -10%, #d9ebe1 0%, transparent 55%), + radial-gradient(900px 400px at 100% 0%, #efe6d6 0%, transparent 50%), + var(--bg); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-width: 320px; + min-height: 100vh; +} + +#root { + min-height: 100vh; +} + +button, +input { + font: inherit; +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..d9cc93c --- /dev/null +++ b/frontend/src/main.jsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import './index.css'; +import App from './App.jsx'; + +createRoot(document.getElementById('root')).render( + + + +); diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx new file mode 100644 index 0000000..a5fb38e --- /dev/null +++ b/frontend/src/pages/DashboardPage.jsx @@ -0,0 +1,130 @@ +import { useEffect, useState } from 'react'; +import { Link, Navigate, useNavigate } from 'react-router-dom'; +import { fetchDashboardStats, fetchDashboardSurveys } from '../api'; +import { clearSession, getUser, isLoggedIn } from '../auth'; + +export default function DashboardPage() { + const navigate = useNavigate(); + const loggedIn = isLoggedIn(); + const user = getUser(); + const [stats, setStats] = useState(null); + const [surveys, setSurveys] = useState([]); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!loggedIn) return undefined; + let cancelled = false; + (async () => { + setLoading(true); + setError(''); + try { + const [statsRes, surveysRes] = await Promise.all([ + fetchDashboardStats(), + fetchDashboardSurveys(50, 0), + ]); + if (cancelled) return; + setStats(statsRes.data); + setSurveys(surveysRes.data?.items || []); + } catch (err) { + if (!cancelled) { + if (err.status === 401) { + clearSession(); + navigate('/login'); + return; + } + setError(err.message || 'Unable to load dashboard'); + } + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, [loggedIn, navigate]); + + if (!loggedIn) { + return ; + } + + const logout = () => { + clearSession(); + navigate('/login'); + }; + + return ( +
+
+
+

TGICS Dashboard

+

{user?.email || 'Admin'}

+
+ +
+ + {loading ?
Loading…
: null} + {error ?

{error}

: null} + + {!loading && stats ? ( +
+
+ Total surveys + {stats.total_surveys} +
+
+ With cough + {stats.with_cough} +
+
+ Without cough + {stats.without_cough} +
+
+ Doctors with surveys + {stats.doctors_with_surveys} +
+
+ ) : null} + + {!loading ? ( +
+

Recent surveys

+ {surveys.length === 0 ? ( +

No surveys submitted yet.

+ ) : ( +
+ + + + + + + + + + + + {surveys.map((row) => ( + + + + + + + + ))} + +
IDContactUserCoughSubmitted
{row.id}{row.contact_id}{row.user_id}{row.has_cough ? 'Yes' : 'No'}{row.created_at ? new Date(row.created_at).toLocaleString() : '-'}
+
+ )} +

+ Public home +

+
+ ) : null} +
+ ); +} diff --git a/frontend/src/pages/HomePage.jsx b/frontend/src/pages/HomePage.jsx new file mode 100644 index 0000000..f37d948 --- /dev/null +++ b/frontend/src/pages/HomePage.jsx @@ -0,0 +1,29 @@ +import { Link } from 'react-router-dom'; + +export default function HomePage() { + return ( +
+
+

TGICS

+

The Great Indian Cough Study

+
+
+

Invalid or missing link

+

+ Please open the WhatsApp link shared with you after doctor enrolment. + The URL looks like: +

+ https://tgics.cttr.in/<user_id> +

+ On that page, enter your enrolled WhatsApp mobile number to continue. +

+

+ If you believe this is an error, contact your LifeMankind representative. +

+

+ Dashboard login +

+
+
+ ); +} diff --git a/frontend/src/pages/LoginPage.jsx b/frontend/src/pages/LoginPage.jsx new file mode 100644 index 0000000..8dcb282 --- /dev/null +++ b/frontend/src/pages/LoginPage.jsx @@ -0,0 +1,76 @@ +import { useState } from 'react'; +import { Link, Navigate, useNavigate } from 'react-router-dom'; +import { login } from '../api'; +import { isLoggedIn, setSession } from '../auth'; + +export default function LoginPage() { + const navigate = useNavigate(); + const [email, setEmail] = useState('ravi@stratawiz.com'); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + if (isLoggedIn()) { + return ; + } + + const onSubmit = async (e) => { + e.preventDefault(); + setError(''); + setLoading(true); + try { + const res = await login(email.trim(), password); + setSession(res.data.token, res.data.user); + navigate('/dashboard'); + } catch (err) { + setError(err.message || 'Login failed'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+

TGICS

+

Dashboard login

+
+
+

Sign in

+
+
+ + setEmail(e.target.value)} + required + /> +
+
+ + setPassword(e.target.value)} + required + /> +
+ {error ?

{error}

: null} + +
+

+ Back +

+
+
+ ); +} diff --git a/frontend/src/pages/SurveyPage.jsx b/frontend/src/pages/SurveyPage.jsx new file mode 100644 index 0000000..bc16d71 --- /dev/null +++ b/frontend/src/pages/SurveyPage.jsx @@ -0,0 +1,406 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { fetchDoctor, fetchPatients, loginDoctorByPhone, submitSurvey } from '../api'; +import QuestionField from '../components/QuestionField'; +import { + QUESTIONS, + STEPS, + fieldsForStep, + validateStep, +} from '../questionnaire'; + +const emptyAnswers = () => ({ + gender: '', + patient_age: '', + exposure_history: [], + occupation_exposure: [], + family_history: [], + past_disease_history: [], + organ_system: [], + ailment: [], + ailment_other: '', + has_cough: '', + cough_duration: '', + cough_type: '', + sputum_quantity: '', + sputum_colour: '', + cough_associated: [], + cough_associated_other: '', + cough_diagnosis: '', + cough_diagnosis_other: '', +}); + +function formatSubmittedAt(value) { + if (!value) return '-'; + try { + return new Date(value).toLocaleString(); + } catch { + return String(value); + } +} + +export default function SurveyPage() { + const { userId, contactId } = useParams(); + const [loading, setLoading] = useState(Boolean(contactId)); + const [error, setError] = useState(''); + const [doctor, setDoctor] = useState(null); + const [phone, setPhone] = useState(''); + const [loginError, setLoginError] = useState(''); + const [loggingIn, setLoggingIn] = useState(false); + + const [view, setView] = useState('list'); // list | form + const [patients, setPatients] = useState([]); + const [patientsLoading, setPatientsLoading] = useState(false); + const [patientsError, setPatientsError] = useState(''); + + const [stepIndex, setStepIndex] = useState(0); + const [answers, setAnswers] = useState(emptyAnswers); + const [submitting, setSubmitting] = useState(false); + const [formError, setFormError] = useState(''); + + const loadPatients = useCallback(async (doctorContactId) => { + if (!doctorContactId) { + setPatients([]); + return; + } + setPatientsLoading(true); + setPatientsError(''); + try { + const res = await fetchPatients(doctorContactId); + setPatients(Array.isArray(res.data?.items) ? res.data.items : []); + } catch (err) { + setPatientsError(err.message || 'Unable to load patients.'); + setPatients([]); + } finally { + setPatientsLoading(false); + } + }, []); + + // Legacy deep link: /:userId/:contactId still works + useEffect(() => { + if (!contactId) { + setLoading(false); + return undefined; + } + let cancelled = false; + (async () => { + setLoading(true); + setError(''); + try { + const res = await fetchDoctor(userId, contactId); + if (cancelled) return; + setDoctor(res.data?.doctor || null); + setView('list'); + } catch (err) { + if (!cancelled) { + setError(err.message || 'Unable to load doctor details.'); + } + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, [userId, contactId]); + + useEffect(() => { + if (doctor?.contact_id) { + loadPatients(doctor.contact_id); + } + }, [doctor?.contact_id, loadPatients]); + + const handlePhoneLogin = async (event) => { + event.preventDefault(); + setLoginError(''); + setLoggingIn(true); + try { + const res = await loginDoctorByPhone(userId, phone); + setDoctor(res.data?.doctor || null); + setView('list'); + } catch (err) { + setLoginError(err.message || 'Unable to verify mobile number.'); + } finally { + setLoggingIn(false); + } + }; + + const openAddPatient = () => { + setAnswers(emptyAnswers()); + setStepIndex(0); + setFormError(''); + setView('form'); + }; + + const backToList = () => { + setAnswers(emptyAnswers()); + setStepIndex(0); + setFormError(''); + setView('list'); + }; + + const visibleSteps = useMemo(() => { + if (answers.has_cough === 'no') { + return STEPS.filter((s) => s.id !== 'cough_detail'); + } + return STEPS; + }, [answers.has_cough]); + + const currentStep = visibleSteps[stepIndex] || visibleSteps[0]; + const stepFields = fieldsForStep(currentStep?.id, answers); + + const setField = (code, value) => { + setAnswers((prev) => ({ ...prev, [code]: value })); + setFormError(''); + }; + + const goNext = () => { + const err = validateStep(currentStep.id, answers); + if (err) { + setFormError(err); + return; + } + if (currentStep.id === 'cough' && answers.has_cough === 'no') { + handleSubmit(); + return; + } + if (stepIndex >= visibleSteps.length - 1) { + handleSubmit(); + return; + } + setStepIndex((i) => i + 1); + }; + + const goBack = () => { + setFormError(''); + if (stepIndex <= 0) { + backToList(); + return; + } + setStepIndex((i) => Math.max(0, i - 1)); + }; + + const handleSubmit = async () => { + const err = validateStep(currentStep.id, answers); + if (err) { + setFormError(err); + return; + } + if (!doctor?.contact_id) { + setFormError('Doctor session expired. Please log in again.'); + return; + } + setSubmitting(true); + setFormError(''); + try { + await submitSurvey({ + user_id: userId, + contact_id: doctor.contact_id, + answers, + }); + setAnswers(emptyAnswers()); + setStepIndex(0); + setView('list'); + await loadPatients(doctor.contact_id); + } catch (e) { + setFormError(e.message || 'Unable to submit survey.'); + } finally { + setSubmitting(false); + } + }; + + const switchDoctor = () => { + setDoctor(null); + setPhone(''); + setLoginError(''); + setAnswers(emptyAnswers()); + setStepIndex(0); + setFormError(''); + setError(''); + setPatients([]); + setPatientsError(''); + setView('list'); + }; + + if (loading) { + return ( +
+
Loading doctor details…
+
+ ); + } + + if (error) { + return ( +
+
+

TGICS

+

The Great Indian Cough Study

+
+
+

Unable to open survey

+

{error}

+
+
+ ); + } + + if (!doctor) { + return ( +
+
+

TGICS

+

The Great Indian Cough Study

+
+
+

Doctor login

+

+ Enter the WhatsApp mobile number used during enrolment to continue. +

+
+
+ + setPhone(e.target.value)} + required + /> +
+ {loginError ?

{loginError}

: null} + +
+
+
+ ); + } + + const progress = Math.round(((stepIndex + 1) / visibleSteps.length) * 100); + + return ( +
+
+

TGICS

+

The Great Indian Cough Study

+
+ +
+
{doctor.name || 'Doctor'}
+
+ {doctor.wfsa_code ? WFSA: {doctor.wfsa_code} : null} + {doctor.city ? {doctor.city}{doctor.state ? `, ${doctor.state}` : ''} : null} + {!contactId ? ( + + ) : null} +
+
+ + {view === 'list' ? ( +
+
+
+

Patients

+

+ {patientsLoading ? 'Loading…' : `${patients.length} patient${patients.length === 1 ? '' : 's'} surveyed`} +

+
+ +
+ + {patientsError ?

{patientsError}

: null} + + {!patientsLoading && patients.length === 0 ? ( +

No patients yet. Tap Add Patient to start a survey.

+ ) : null} + + {patients.length > 0 ? ( +
+ + + + + + + + + + + + {patients.map((row, index) => ( + + + + + + + + ))} + +
#GenderAgeCoughSubmitted
{patients.length - index}{row.gender ? String(row.gender).replace(/_/g, ' ') : '-'}{row.patient_age !== '' && row.patient_age != null ? row.patient_age : '-'}{row.has_cough ? 'Yes' : 'No'}{formatSubmittedAt(row.created_at)}
+
+ ) : null} +
+ ) : ( +
+
+

Add Patient

+ +
+ +
+
+
+

+ Step {stepIndex + 1} of {visibleSteps.length}: {currentStep.title} +

+ + {stepFields.map((code) => { + const q = QUESTIONS[code]; + return ( + setField(code, v)} + onOtherChange={(v) => setField(q.otherField, v)} + /> + ); + })} + + {formError ?

{formError}

: null} + +
+ + +
+
+ )} +
+ ); +} diff --git a/frontend/src/questionnaire.js b/frontend/src/questionnaire.js new file mode 100644 index 0000000..314760f --- /dev/null +++ b/frontend/src/questionnaire.js @@ -0,0 +1,314 @@ +/** Client-side copy of TGICS questionnaire options (labels + branching). */ + +export const QUESTIONS = { + gender: { + code: 'gender', + label: 'Gender', + type: 'single', + required: true, + options: [ + { value: 'male', label: 'Male' }, + { value: 'female', label: 'Female' }, + { value: 'prefer_not_to_say', label: 'Prefer not to say' }, + ], + }, + patient_age: { + code: 'patient_age', + label: 'Age (in completed years)', + type: 'number', + required: true, + min: 0, + max: 120, + }, + exposure_history: { + code: 'exposure_history', + label: 'History of exposure?', + type: 'multi', + required: true, + options: [ + { value: 'never_smoker', label: 'Never smoker' }, + { value: 'ex_smoker', label: 'Ex-smoker' }, + { value: 'current_smoker', label: 'Current smoker' }, + { + value: 'pulmonary_tb_contact', + label: 'Suffered or ever came in contact with a patient who had Pulmonary TB', + }, + { + value: 'indoor_air_pollution', + label: 'Current exposure to any sources of Indoor Air Pollution', + }, + ], + }, + occupation_exposure: { + code: 'occupation_exposure', + label: 'Work/occupation exposed to', + type: 'multi', + required: true, + options: [ + { value: 'smoke', label: 'Smoke' }, + { value: 'fumes', label: 'Fumes' }, + { value: 'dust', label: 'Dust' }, + { value: 'gases', label: 'Gases' }, + { value: 'none', label: 'None' }, + ], + }, + family_history: { + code: 'family_history', + label: 'Family history', + type: 'multi', + required: true, + options: [ + { + value: 'allergic_dermatitis_rhinitis_migraine', + label: 'Allergic Dermatitis, Allergic Rhinitis, Migraine', + }, + { value: 'asthma', label: 'Asthma' }, + { value: 'gerd', label: 'GERD (Gastroesophageal Reflux Disease)' }, + { value: 'tb', label: 'TB' }, + { value: 'rheumatic', label: 'Rheumatic diseases (arthritis, heart disease)' }, + { value: 'cardiovascular', label: 'Cardiovascular diseases' }, + { value: 'no_specific', label: 'No specific' }, + ], + }, + past_disease_history: { + code: 'past_disease_history', + label: 'Past disease history of patient', + type: 'multi', + required: true, + options: [ + { + value: 'allergic_dermatitis_rhinitis_migraine', + label: 'Allergic Dermatitis, Allergic Rhinitis, Migraine', + }, + { value: 'asthma', label: 'Asthma' }, + { value: 'copd', label: 'COPD' }, + { value: 'tb', label: 'TB' }, + { value: 'sinus_issues', label: 'Sinus issues (Post Nasal Drip)' }, + { value: 'gerd', label: 'GERD (Gastroesophageal Reflux Disease)' }, + { value: 'rheumatic', label: 'Rheumatic diseases (arthritis, heart disease)' }, + { value: 'other_lung_disease', label: 'Any other lung disease' }, + { value: 'no_history', label: 'No history of any disease' }, + ], + }, + organ_system: { + code: 'organ_system', + label: 'What organ/system is involved for which the patient is visiting the doctor today?', + type: 'multi', + required: true, + options: [ + { value: 'skin', label: 'Skin' }, + { value: 'brain_spinal', label: 'Brain & spinal cord' }, + { value: 'ent', label: 'Ear/Nose/Throat' }, + { value: 'heart', label: 'Heart disease' }, + { value: 'lungs', label: 'Lungs & breathing tubes' }, + { value: 'abdomen', label: 'Abdomen' }, + { value: 'kidney', label: 'Kidney' }, + { value: 'pregnancy_reproductive', label: 'Pregnancy & reproductive issues' }, + { value: 'muscles_bones_joints', label: 'Muscles/Bones/Joints' }, + ], + }, + ailment: { + code: 'ailment', + label: 'For which ailment is the patient visiting the Doctor today?', + type: 'multi', + required: true, + otherField: 'ailment_other', + options: [ + { value: 'fever', label: 'Fever' }, + { value: 'high_bp', label: 'High blood pressure' }, + { value: 'diabetes', label: 'Diabetes' }, + { value: 'anaemia', label: 'Anaemia' }, + { value: 'stomach_upset', label: 'Stomach upset (vomiting / loose motions)' }, + { value: 'cold_throat', label: 'Cold (runny nose) &/or throat irritation/pain' }, + { value: 'thyroid', label: 'Thyroid' }, + { value: 'rashes', label: 'Rashes (any where)' }, + { value: 'swelling', label: 'Swelling on feet / body' }, + { value: 'breathlessness', label: 'Breathlessness' }, + { value: 'backache_weakness', label: 'Back ache / Weakness / Joint pain / Body ache' }, + { value: 'foreign_body', label: 'Foreign body in eye/ear/nose/throat' }, + { value: 'physical_injury', label: 'Physical injury' }, + { value: 'others', label: 'Others (please specify briefly)' }, + ], + }, + has_cough: { + code: 'has_cough', + label: 'Does the patient complain of cough?', + type: 'single', + required: true, + options: [ + { value: 'yes', label: 'Yes' }, + { value: 'no', label: 'No' }, + ], + }, + cough_duration: { + code: 'cough_duration', + label: 'What is the duration of cough?', + type: 'single', + required: true, + options: [ + { value: 'lt_7_days', label: 'Less than 7 days' }, + { value: '7_days_to_3_weeks', label: '7 days to <3 weeks' }, + { value: '3_weeks_to_2_months', label: '3 weeks to 2 months' }, + { value: 'gt_2_months', label: 'More than 2 months' }, + ], + }, + cough_type: { + code: 'cough_type', + label: 'Is the cough dry or wet?', + type: 'single', + required: true, + options: [ + { value: 'dry', label: 'Dry' }, + { value: 'wet', label: 'Wet (with sputum/mucus/phlegm)' }, + ], + }, + sputum_quantity: { + code: 'sputum_quantity', + label: 'How much is the quantity of sputum/mucus/phlegm?', + type: 'single', + required: true, + options: [ + { value: 'scanty', label: 'Scanty' }, + { value: 'moderate', label: 'Moderate' }, + { value: 'excess', label: 'Excess' }, + { value: 'sometimes_excess_sometimes_less', label: 'Sometimes excess and sometimes less' }, + { value: 'more_early_morning', label: 'More in early morning' }, + ], + }, + sputum_colour: { + code: 'sputum_colour', + label: 'What is the colour of the sputum/mucus/phlegm?', + type: 'single', + required: true, + options: [ + { value: 'white', label: 'White' }, + { value: 'yellow', label: 'Yellow' }, + { value: 'green', label: 'Green' }, + { value: 'brownish', label: 'Brownish' }, + { value: 'pink', label: 'Pink' }, + { value: 'blood_tinged', label: 'Blood tinged' }, + ], + }, + cough_associated: { + code: 'cough_associated', + label: 'Is cough associated with?', + type: 'multi', + required: true, + otherField: 'cough_associated_other', + options: [ + { value: 'fever', label: 'Fever' }, + { value: 'weight_loss', label: 'Weight loss' }, + { value: 'loss_of_appetite', label: 'Loss of appetite' }, + { value: 'evening_rise_fever', label: 'Evening rise of fever' }, + { value: 'difficulty_breathing', label: 'Difficulty in breathing' }, + { value: 'wheeze', label: 'Whistling sound from chest/ wheeze' }, + { value: 'chest_pain', label: 'Chest pain' }, + { value: 'hoarseness', label: 'Hoarseness of voice' }, + { value: 'sinusitis', label: 'Sinusitis (Post Nasal Drip)' }, + { value: 'acid_reflux', label: 'Acid Reflux/Regurgitation' }, + { value: 'night_sweats', label: 'Night sweats' }, + { value: 'others', label: 'Others (please specify)' }, + { value: 'none', label: 'None of the above' }, + ], + }, + cough_diagnosis: { + code: 'cough_diagnosis', + label: 'What is the diagnosis for cough?', + type: 'single', + required: true, + otherField: 'cough_diagnosis_other', + options: [ + { value: 'common_cold', label: 'Common cold' }, + { value: 'sinusitis', label: 'Sinusitis' }, + { value: 'allergic_cough', label: 'Allergic Cough' }, + { value: 'allergic_rhinitis', label: 'Allergic Rhinitis' }, + { value: 'asthma', label: 'Asthma' }, + { value: 'drug_induced', label: 'Drug induced cough' }, + { value: 'acidity_gerd', label: 'Acidity induced cough (GERD)' }, + { value: 'post_nasal_drip', label: 'Post Nasal Drip (Upper Airway Cough Syndrome)' }, + { value: 'tb', label: 'TB' }, + { value: 'copd', label: 'COPD' }, + { value: 'others', label: 'Others (please specify)' }, + { value: 'not_sure', label: 'Not sure of diagnosis' }, + ], + }, +}; + +export const STEPS = [ + { + id: 'basics', + title: 'Patient details', + fields: ['gender', 'patient_age'], + }, + { + id: 'history', + title: 'History & exposure', + fields: [ + 'exposure_history', + 'occupation_exposure', + 'family_history', + 'past_disease_history', + 'organ_system', + 'ailment', + ], + }, + { + id: 'cough', + title: 'Cough assessment', + fields: ['has_cough'], + }, + { + id: 'cough_detail', + title: 'Cough details', + fields: [ + 'cough_duration', + 'cough_type', + 'sputum_quantity', + 'sputum_colour', + 'cough_associated', + 'cough_diagnosis', + ], + }, +]; + +export function fieldsForStep(stepId, answers) { + const step = STEPS.find((s) => s.id === stepId); + if (!step) return []; + if (stepId !== 'cough_detail') return step.fields; + + if (answers.has_cough !== 'yes') return []; + + const fields = ['cough_duration', 'cough_type']; + if (answers.cough_type === 'wet') { + fields.push('sputum_quantity', 'sputum_colour'); + } + fields.push('cough_associated', 'cough_diagnosis'); + return fields; +} + +export function validateStep(stepId, answers) { + const fields = fieldsForStep(stepId, answers); + for (const code of fields) { + const q = QUESTIONS[code]; + if (!q) continue; + if (q.type === 'number') { + const n = Number(answers[code]); + if (!Number.isFinite(n) || n < (q.min ?? 0) || n > (q.max ?? 120)) { + return `Please enter a valid ${q.label.toLowerCase()}.`; + } + } else if (q.type === 'single') { + if (!answers[code]) return `Please select ${q.label}.`; + } else if (q.type === 'multi') { + if (!Array.isArray(answers[code]) || answers[code].length === 0) { + return `Please select at least one option for ${q.label}.`; + } + } + if (q.otherField) { + const selected = Array.isArray(answers[code]) ? answers[code] : [answers[code]]; + if (selected.includes('others') && !String(answers[q.otherField] || '').trim()) { + return `Please specify details for ${q.label}.`; + } + } + } + return null; +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..9982072 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,7 @@ +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vite' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], +})