first commit
This commit is contained in:
commit
5f4ea78300
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
node_modules/
|
||||
.env
|
||||
*.log
|
||||
.DS_Store
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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,
|
||||
},
|
||||
];
|
||||
|
|
@ -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,
|
||||
},
|
||||
];
|
||||
|
|
@ -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,
|
||||
},
|
||||
];
|
||||
|
|
@ -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,
|
||||
},
|
||||
];
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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();
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -0,0 +1 @@
|
|||
VITE_API_BASE=http://localhost:3050
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
.DS_Store
|
||||
|
|
@ -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 }]
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>TGICS — The Great Indian Cough Study</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
|
|
@ -0,0 +1,24 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/dashboard" element={<DashboardPage />} />
|
||||
<Route path="/:userId/:contactId" element={<SurveyPage />} />
|
||||
<Route path="/:userId" element={<SurveyPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}`);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
export default function QuestionField({ question, value, otherValue, onChange, onOtherChange }) {
|
||||
if (!question) return null;
|
||||
|
||||
if (question.type === 'number') {
|
||||
return (
|
||||
<div className="field">
|
||||
<label className="field-label" htmlFor={question.code}>
|
||||
{question.label} <span className="req">*</span>
|
||||
</label>
|
||||
<input
|
||||
id={question.code}
|
||||
type="number"
|
||||
min={question.min ?? 0}
|
||||
max={question.max ?? 120}
|
||||
className="field-input"
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value === '' ? '' : Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (question.type === 'single') {
|
||||
return (
|
||||
<fieldset className="field">
|
||||
<legend className="field-label">
|
||||
{question.label} <span className="req">*</span>
|
||||
</legend>
|
||||
<div className="option-list">
|
||||
{question.options.map((opt) => (
|
||||
<label key={opt.value} className="option-row">
|
||||
<input
|
||||
type="radio"
|
||||
name={question.code}
|
||||
value={opt.value}
|
||||
checked={value === opt.value}
|
||||
onChange={() => onChange(opt.value)}
|
||||
/>
|
||||
<span>{opt.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{question.otherField && value === 'others' && (
|
||||
<input
|
||||
className="field-input mt"
|
||||
type="text"
|
||||
placeholder="Please specify"
|
||||
value={otherValue || ''}
|
||||
onChange={(e) => onOtherChange(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
const selected = Array.isArray(value) ? value : [];
|
||||
|
||||
return (
|
||||
<fieldset className="field">
|
||||
<legend className="field-label">
|
||||
{question.label} <span className="req">*</span>
|
||||
</legend>
|
||||
<div className="option-list">
|
||||
{question.options.map((opt) => {
|
||||
const checked = selected.includes(opt.value);
|
||||
return (
|
||||
<label key={opt.value} className="option-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
value={opt.value}
|
||||
checked={checked}
|
||||
onChange={() => {
|
||||
if (checked) {
|
||||
onChange(selected.filter((v) => v !== opt.value));
|
||||
} else {
|
||||
onChange([...selected, opt.value]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span>{opt.label}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{question.otherField && selected.includes('others') && (
|
||||
<input
|
||||
className="field-input mt"
|
||||
type="text"
|
||||
placeholder="Please specify"
|
||||
value={otherValue || ''}
|
||||
onChange={(e) => onOtherChange(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
|
|
@ -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 <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
clearSession();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page page-wide">
|
||||
<header className="brand-bar dash-header">
|
||||
<div>
|
||||
<h1>TGICS Dashboard</h1>
|
||||
<p>{user?.email || 'Admin'}</p>
|
||||
</div>
|
||||
<button type="button" className="btn ghost" onClick={logout}>
|
||||
Logout
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{loading ? <div className="card center">Loading…</div> : null}
|
||||
{error ? <p className="error">{error}</p> : null}
|
||||
|
||||
{!loading && stats ? (
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card">
|
||||
<small>Total surveys</small>
|
||||
<strong>{stats.total_surveys}</strong>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<small>With cough</small>
|
||||
<strong>{stats.with_cough}</strong>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<small>Without cough</small>
|
||||
<strong>{stats.without_cough}</strong>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<small>Doctors with surveys</small>
|
||||
<strong>{stats.doctors_with_surveys}</strong>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!loading ? (
|
||||
<main className="card">
|
||||
<h2>Recent surveys</h2>
|
||||
{surveys.length === 0 ? (
|
||||
<p className="muted">No surveys submitted yet.</p>
|
||||
) : (
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Contact</th>
|
||||
<th>User</th>
|
||||
<th>Cough</th>
|
||||
<th>Submitted</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{surveys.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td>{row.id}</td>
|
||||
<td className="mono">{row.contact_id}</td>
|
||||
<td className="mono">{row.user_id}</td>
|
||||
<td>{row.has_cough ? 'Yes' : 'No'}</td>
|
||||
<td>{row.created_at ? new Date(row.created_at).toLocaleString() : '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<p className="muted small" style={{ marginTop: '1rem' }}>
|
||||
<Link className="text-link" to="/">Public home</Link>
|
||||
</p>
|
||||
</main>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import { Link } from 'react-router-dom';
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<div className="page">
|
||||
<header className="brand-bar">
|
||||
<h1>TGICS</h1>
|
||||
<p>The Great Indian Cough Study</p>
|
||||
</header>
|
||||
<main className="card">
|
||||
<h2>Invalid or missing link</h2>
|
||||
<p className="muted">
|
||||
Please open the WhatsApp link shared with you after doctor enrolment.
|
||||
The URL looks like:
|
||||
</p>
|
||||
<code className="code-block">https://tgics.cttr.in/<user_id></code>
|
||||
<p className="muted small">
|
||||
On that page, enter your enrolled WhatsApp mobile number to continue.
|
||||
</p>
|
||||
<p className="muted small">
|
||||
If you believe this is an error, contact your LifeMankind representative.
|
||||
</p>
|
||||
<p className="muted small">
|
||||
<Link className="text-link" to="/login">Dashboard login</Link>
|
||||
</p>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 <Navigate to="/dashboard" replace />;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="page">
|
||||
<header className="brand-bar">
|
||||
<h1>TGICS</h1>
|
||||
<p>Dashboard login</p>
|
||||
</header>
|
||||
<main className="card">
|
||||
<h2>Sign in</h2>
|
||||
<form onSubmit={onSubmit} className="login-form">
|
||||
<div className="field">
|
||||
<label className="field-label" htmlFor="email">Email</label>
|
||||
<input
|
||||
id="email"
|
||||
className="field-input"
|
||||
type="email"
|
||||
autoComplete="username"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="field-label" htmlFor="password">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
className="field-input"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{error ? <p className="error">{error}</p> : null}
|
||||
<button type="submit" className="btn primary" disabled={loading}>
|
||||
{loading ? 'Signing in…' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
<p className="muted small" style={{ marginTop: '1rem' }}>
|
||||
<Link className="text-link" to="/">Back</Link>
|
||||
</p>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className="page">
|
||||
<div className="card center">Loading doctor details…</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="page">
|
||||
<header className="brand-bar">
|
||||
<h1>TGICS</h1>
|
||||
<p>The Great Indian Cough Study</p>
|
||||
</header>
|
||||
<main className="card">
|
||||
<h2>Unable to open survey</h2>
|
||||
<p className="muted">{error}</p>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!doctor) {
|
||||
return (
|
||||
<div className="page">
|
||||
<header className="brand-bar">
|
||||
<h1>TGICS</h1>
|
||||
<p>The Great Indian Cough Study</p>
|
||||
</header>
|
||||
<main className="card">
|
||||
<h2>Doctor login</h2>
|
||||
<p className="muted">
|
||||
Enter the WhatsApp mobile number used during enrolment to continue.
|
||||
</p>
|
||||
<form className="login-form" onSubmit={handlePhoneLogin}>
|
||||
<div className="field">
|
||||
<label className="field-label" htmlFor="doctor-phone">
|
||||
WhatsApp mobile number <span className="req">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="doctor-phone"
|
||||
type="tel"
|
||||
inputMode="numeric"
|
||||
autoComplete="tel"
|
||||
className="field-input"
|
||||
placeholder="10-digit mobile number"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{loginError ? <p className="error">{loginError}</p> : null}
|
||||
<button type="submit" className="btn primary" disabled={loggingIn}>
|
||||
{loggingIn ? 'Verifying…' : 'Continue'}
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const progress = Math.round(((stepIndex + 1) / visibleSteps.length) * 100);
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<header className="brand-bar">
|
||||
<h1>TGICS</h1>
|
||||
<p>The Great Indian Cough Study</p>
|
||||
</header>
|
||||
|
||||
<section className="doctor-card">
|
||||
<div className="doctor-name">{doctor.name || 'Doctor'}</div>
|
||||
<div className="doctor-meta">
|
||||
{doctor.wfsa_code ? <span>WFSA: {doctor.wfsa_code}</span> : null}
|
||||
{doctor.city ? <span>{doctor.city}{doctor.state ? `, ${doctor.state}` : ''}</span> : null}
|
||||
{!contactId ? (
|
||||
<button type="button" className="text-link" onClick={switchDoctor}>
|
||||
Not you? Switch
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{view === 'list' ? (
|
||||
<main className="card">
|
||||
<div className="list-header">
|
||||
<div>
|
||||
<h2 className="list-title">Patients</h2>
|
||||
<p className="muted small">
|
||||
{patientsLoading ? 'Loading…' : `${patients.length} patient${patients.length === 1 ? '' : 's'} surveyed`}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" className="btn primary" onClick={openAddPatient}>
|
||||
Add Patient
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{patientsError ? <p className="error">{patientsError}</p> : null}
|
||||
|
||||
{!patientsLoading && patients.length === 0 ? (
|
||||
<p className="muted center-empty">No patients yet. Tap Add Patient to start a survey.</p>
|
||||
) : null}
|
||||
|
||||
{patients.length > 0 ? (
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Gender</th>
|
||||
<th>Age</th>
|
||||
<th>Cough</th>
|
||||
<th>Submitted</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{patients.map((row, index) => (
|
||||
<tr key={row.id}>
|
||||
<td>{patients.length - index}</td>
|
||||
<td>{row.gender ? String(row.gender).replace(/_/g, ' ') : '-'}</td>
|
||||
<td>{row.patient_age !== '' && row.patient_age != null ? row.patient_age : '-'}</td>
|
||||
<td>{row.has_cough ? 'Yes' : 'No'}</td>
|
||||
<td>{formatSubmittedAt(row.created_at)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
</main>
|
||||
) : (
|
||||
<main className="card">
|
||||
<div className="list-header">
|
||||
<h2 className="list-title">Add Patient</h2>
|
||||
<button type="button" className="btn ghost" onClick={backToList} disabled={submitting}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="progress-wrap">
|
||||
<div className="progress-bar" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
<p className="step-label">
|
||||
Step {stepIndex + 1} of {visibleSteps.length}: {currentStep.title}
|
||||
</p>
|
||||
|
||||
{stepFields.map((code) => {
|
||||
const q = QUESTIONS[code];
|
||||
return (
|
||||
<QuestionField
|
||||
key={code}
|
||||
question={q}
|
||||
value={answers[code]}
|
||||
otherValue={q.otherField ? answers[q.otherField] : ''}
|
||||
onChange={(v) => setField(code, v)}
|
||||
onOtherChange={(v) => setField(q.otherField, v)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{formError ? <p className="error">{formError}</p> : null}
|
||||
|
||||
<div className="actions">
|
||||
<button type="button" className="btn ghost" onClick={goBack} disabled={submitting}>
|
||||
{stepIndex > 0 ? 'Back' : 'Back to list'}
|
||||
</button>
|
||||
<button type="button" className="btn primary" onClick={goNext} disabled={submitting}>
|
||||
{submitting
|
||||
? 'Submitting…'
|
||||
: currentStep.id === 'cough' && answers.has_cough === 'no'
|
||||
? 'Submit'
|
||||
: stepIndex >= visibleSteps.length - 1
|
||||
? 'Submit'
|
||||
: 'Next'}
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import react from '@vitejs/plugin-react'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
Loading…
Reference in New Issue