From bb3f8b37a0f408f5c479f3946d62708e759a38f9 Mon Sep 17 00:00:00 2001 From: Amey Bhagwat Date: Mon, 19 Oct 2026 09:56:57 +0530 Subject: [PATCH] #Added user (doctor) login - email -> OTP - resend otp #Added Consent step before agreement (application) #Added tracking for each step completion status #User will be redirected automatically to particular step based on steps completed. #Dashboard for user (doctor) #Added upload cancelled cheque in agreement bank details. --- README.md | 11 + .../20261016120000_users_and_otps.js | 54 ++ backend/migrations/20261016180000_consents.js | 38 ++ .../20261017190000_cancelled_cheque.js | 18 + backend/src/app.js | 14 + backend/src/db/seedSurvey.js | 4 +- backend/src/middleware/requireUser.js | 40 ++ backend/src/pdf/agreementHtml.js | 36 +- backend/src/pdf/render.js | 42 +- backend/src/routes/applications.js | 12 +- backend/src/routes/consent.js | 37 ++ backend/src/routes/index.js | 4 + backend/src/routes/survey.js | 21 +- backend/src/routes/userAuth.js | 118 ++++ backend/src/schemas/application.js | 2 + backend/src/schemas/consent.js | 36 ++ backend/src/services/admin.js | 130 +--- backend/src/services/agreementDocuments.js | 39 +- backend/src/services/applicationApproval.js | 156 +++++ backend/src/services/applications.js | 74 ++- backend/src/services/consent.js | 94 +++ backend/src/services/emailTemplates.js | 14 +- backend/src/services/mail.js | 15 +- backend/src/services/png.js | 38 ++ backend/src/services/storage.js | 2 +- backend/src/services/survey.js | 42 +- backend/src/services/userAuth.js | 316 ++++++++++ backend/src/survey/definition.js | 102 +-- backend/src/survey/seed.js | 20 +- backend/test/agreementHtml.test.js | 10 +- backend/test/applications.test.js | 48 ++ backend/test/consent.test.js | 132 ++++ backend/test/mail.test.js | 4 +- backend/test/renderPdf.test.js | 31 +- backend/test/storage.test.js | 6 +- backend/test/survey.test.js | 8 +- backend/test/surveyExport.test.js | 7 +- backend/test/truncate.js | 3 + backend/test/userAuth.test.js | 120 ++++ files/docs/agreement.md | 6 +- files/docs/pis.md | 65 ++ frontend/package.json | 1 + frontend/src/App.jsx | 56 +- frontend/src/App.test.jsx | 76 ++- frontend/src/api.js | 6 + frontend/src/consentQuestions.js | 57 ++ frontend/src/index.css | 436 ++++++++++++- frontend/src/pages/AgreementPage.jsx | 592 +++++++++++++++--- frontend/src/pages/AgreementPage.test.jsx | 66 +- frontend/src/pages/ConsentPage.jsx | 205 ++++++ frontend/src/pages/ConsentPage.test.jsx | 95 +++ frontend/src/pages/ReviewPage.jsx | 11 +- frontend/src/pages/SignPage.jsx | 44 +- frontend/src/pages/SubmittedPage.jsx | 61 +- frontend/src/pages/SurveyPage.jsx | 12 +- frontend/src/pages/UserDashboardPage.jsx | 300 +++++++++ frontend/src/pages/UserLoginPage.jsx | 195 ++++++ frontend/src/pages/UserLoginPage.test.jsx | 85 +++ frontend/src/user/UserLayout.jsx | 112 ++++ frontend/src/userApi.js | 55 ++ frontend/vite.config.js | 7 +- ... Extended Dose Pack PIS_ICF V01_141026.pdf | Bin 0 -> 556796 bytes ...Study_Consultation Agreement_MOU Final.pdf | Bin 0 -> 491171 bytes ...ded Dose Pack_Questionnaire V01_141026.pdf | Bin 0 -> 586105 bytes help/consultation_agreement.md | 139 ++++ help/study_questionnaire.md | 116 ++++ package-lock.json | 66 -- package.json | 1 + 68 files changed, 4293 insertions(+), 470 deletions(-) create mode 100644 backend/migrations/20261016120000_users_and_otps.js create mode 100644 backend/migrations/20261016180000_consents.js create mode 100644 backend/migrations/20261017190000_cancelled_cheque.js create mode 100644 backend/src/middleware/requireUser.js create mode 100644 backend/src/routes/consent.js create mode 100644 backend/src/routes/userAuth.js create mode 100644 backend/src/schemas/consent.js create mode 100644 backend/src/services/applicationApproval.js create mode 100644 backend/src/services/consent.js create mode 100644 backend/src/services/userAuth.js create mode 100644 backend/test/consent.test.js create mode 100644 backend/test/userAuth.test.js create mode 100644 files/docs/pis.md create mode 100644 frontend/src/consentQuestions.js create mode 100644 frontend/src/pages/ConsentPage.jsx create mode 100644 frontend/src/pages/ConsentPage.test.jsx create mode 100644 frontend/src/pages/UserDashboardPage.jsx create mode 100644 frontend/src/pages/UserLoginPage.jsx create mode 100644 frontend/src/pages/UserLoginPage.test.jsx create mode 100644 frontend/src/user/UserLayout.jsx create mode 100644 frontend/src/userApi.js create mode 100644 help/1. Extended Dose Pack PIS_ICF V01_141026.pdf create mode 100644 help/2. Extended Dose Pack Study_Consultation Agreement_MOU Final.pdf create mode 100644 help/3. Extended Dose Pack_Questionnaire V01_141026.pdf create mode 100644 help/consultation_agreement.md create mode 100644 help/study_questionnaire.md diff --git a/README.md b/README.md index adfee01..add14a5 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,17 @@ npm run dev The Vite dev server proxies `/api` to port 4000. +### Frontend only + +If you are not running the local API, proxy `/api` to the hosted backend: + +```bash +npm install +npm run dev:frontend +``` + +Then open http://localhost:5173. Requests to `/api` go to `https://pppaimt-crest.xltr.in`. Do not submit live agreements unless you intend to change production data. + ## Database Create the application database, then: diff --git a/backend/migrations/20261016120000_users_and_otps.js b/backend/migrations/20261016120000_users_and_otps.js new file mode 100644 index 0000000..35f3745 --- /dev/null +++ b/backend/migrations/20261016120000_users_and_otps.js @@ -0,0 +1,54 @@ +/** + * Physician users (OTP login), OTP codes, and link applications to users. + * Leaves admin_users unchanged. + * @param {import('knex').Knex} knex + */ +export async function up(knex) { + await knex.schema.createTable('users', (table) => { + table.charset('utf8mb4'); + table.collate('utf8mb4_unicode_ci'); + table.increments('id').primary(); + table.string('email', 255).notNullable().unique(); + table.string('name', 255).nullable(); + table.boolean('is_active').notNullable().defaultTo(true); + table.datetime('last_login_at').nullable(); + table.timestamps(true, true); + }); + + await knex.schema.createTable('login_otps', (table) => { + table.charset('utf8mb4'); + table.collate('utf8mb4_unicode_ci'); + table.increments('id').primary(); + table.string('email', 255).notNullable().index(); + table.string('code_hash', 128).notNullable(); + table.datetime('expires_at').notNullable(); + table.integer('attempts').unsigned().notNullable().defaultTo(0); + table.datetime('used_at').nullable(); + table.datetime('created_at').notNullable().defaultTo(knex.fn.now()); + }); + + await knex.schema.alterTable('applications', (table) => { + table + .integer('user_id') + .unsigned() + .nullable() + .unique() + .references('id') + .inTable('users') + .onDelete('SET NULL'); + }); +} + +/** + * @param {import('knex').Knex} knex + */ +export async function down(knex) { + await knex.schema.alterTable('applications', (table) => { + table.dropForeign(['user_id']); + table.dropUnique(['user_id']); + table.dropColumn('user_id'); + }); + + await knex.schema.dropTableIfExists('login_otps'); + await knex.schema.dropTableIfExists('users'); +} diff --git a/backend/migrations/20261016180000_consents.js b/backend/migrations/20261016180000_consents.js new file mode 100644 index 0000000..257bbc5 --- /dev/null +++ b/backend/migrations/20261016180000_consents.js @@ -0,0 +1,38 @@ +/** + * Store PIS/ICF consent submissions for physician users. + * @param {import('knex').Knex} knex + */ +export async function up(knex) { + await knex.schema.createTable('consents', (table) => { + table.charset('utf8mb4'); + table.collate('utf8mb4_unicode_ci'); + table.increments('id').primary(); + table + .integer('user_id') + .unsigned() + .notNullable() + .unique() + .references('id') + .inTable('users') + .onDelete('CASCADE'); + table + .integer('application_id') + .unsigned() + .notNullable() + .references('id') + .inTable('applications') + .onDelete('CASCADE'); + table.string('participant_name', 255).notNullable(); + table.date('consent_date').notNullable(); + table.json('answers').notNullable(); + table.datetime('submitted_at').notNullable(); + table.timestamps(true, true); + }); +} + +/** + * @param {import('knex').Knex} knex + */ +export async function down(knex) { + await knex.schema.dropTableIfExists('consents'); +} diff --git a/backend/migrations/20261017190000_cancelled_cheque.js b/backend/migrations/20261017190000_cancelled_cheque.js new file mode 100644 index 0000000..26f6921 --- /dev/null +++ b/backend/migrations/20261017190000_cancelled_cheque.js @@ -0,0 +1,18 @@ +/** + * Optional cancelled-cheque image key on applications (bank verification). + * @param {import('knex').Knex} knex + */ +export async function up(knex) { + await knex.schema.alterTable('applications', (table) => { + table.string('cancelled_cheque_key', 512).nullable().after('ifsc'); + }); +} + +/** + * @param {import('knex').Knex} knex + */ +export async function down(knex) { + await knex.schema.alterTable('applications', (table) => { + table.dropColumn('cancelled_cheque_key'); + }); +} diff --git a/backend/src/app.js b/backend/src/app.js index f9bbb08..32744b3 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -74,6 +74,7 @@ export async function createServer() { 'req.headers.authorization', 'req.headers.cookie', 'req.payload.password', + 'req.payload.otp', 'req.payload.pan', 'req.payload.account_number', 'req.params.token', @@ -127,6 +128,19 @@ export async function createServer() { }, }); server.auth.default({ strategy: 'admin', mode: 'try' }); + + // Physician session cookie (separate from @hapi/cookie admin strategy). + server.state('user_token', { + ttl: 8 * 60 * 60 * 1000, + isSecure: env.NODE_ENV === 'production', + isHttpOnly: true, + isSameSite: 'Lax', + path: '/', + encoding: 'iron', + password: env.JWT_SECRET, + clearInvalid: true, + ignoreErrors: true, + }); } await registerRoutes(server); diff --git a/backend/src/db/seedSurvey.js b/backend/src/db/seedSurvey.js index 4b98a2a..bbf359a 100644 --- a/backend/src/db/seedSurvey.js +++ b/backend/src/db/seedSurvey.js @@ -1,6 +1,6 @@ import { db } from './knex.js'; -import { seedSurveyDefinition } from '../survey/seed.js'; +import { SURVEY_DEFINITION_VERSION, seedSurveyDefinition } from '../survey/seed.js'; const id = await seedSurveyDefinition(); -console.log(`Survey definition version 1 is ready (${id})`); +console.log(`Survey definition version ${SURVEY_DEFINITION_VERSION} is ready (${id})`); await db.destroy(); diff --git a/backend/src/middleware/requireUser.js b/backend/src/middleware/requireUser.js new file mode 100644 index 0000000..3c3a5e9 --- /dev/null +++ b/backend/src/middleware/requireUser.js @@ -0,0 +1,40 @@ +import Boom from '@hapi/boom'; +import { db } from '../db/knex.js'; +import { readUserId } from '../services/userAuth.js'; + +/** @type {import('@hapi/hapi').RouteOptionsPreObject} */ +export const requireUser = { + assign: 'user', + async method(request) { + const token = jwtFromCookie(request.state.user_token); + const userId = token ? readUserId(token) : null; + + if (!userId) { + throw Boom.unauthorized('Authentication required'); + } + + const user = await db('users').where({ id: Number(userId) }).first(); + + if (!user || !user.is_active) { + throw Boom.unauthorized('Authentication required'); + } + + return { id: user.id, name: user.name ?? null, email: user.email }; + }, +}; + +/** + * @param {unknown} value + * @returns {string | null} + */ +function jwtFromCookie(value) { + if (typeof value === 'string') { + return value; + } + + if (value && typeof value === 'object' && 'token' in value && typeof value.token === 'string') { + return value.token; + } + + return null; +} diff --git a/backend/src/pdf/agreementHtml.js b/backend/src/pdf/agreementHtml.js index 557485f..46a611c 100644 --- a/backend/src/pdf/agreementHtml.js +++ b/backend/src/pdf/agreementHtml.js @@ -2,13 +2,20 @@ import { readFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -const AGREEMENT_PATH = path.resolve( - path.dirname(fileURLToPath(import.meta.url)), - '../../../files/docs/agreement.md', -); +const ROOT = path.dirname(fileURLToPath(import.meta.url)); +const AGREEMENT_PATH = path.resolve(ROOT, '../../../files/docs/agreement.md'); +const LOGO_PATH = path.resolve(ROOT, '../../../frontend/src/images/CREST_logo.jpg'); const sourceText = readFileSync(AGREEMENT_PATH, 'utf8'); +/** @type {string} */ +let crestLogoDataUrl = ''; +try { + crestLogoDataUrl = `data:image/jpeg;base64,${readFileSync(LOGO_PATH).toString('base64')}`; +} catch { + crestLogoDataUrl = ''; +} + const MONTHS = [ 'January', 'February', @@ -80,6 +87,18 @@ function physicianSignatureHtml(signature) { return '___________________'; } +/** + * @param {string} signatureMarkup + * @param {{ withLogo?: boolean }} [options] + */ +function signatureRow(signatureMarkup, options = {}) { + const logo = + options.withLogo && crestLogoDataUrl + ? `` + : ''; + return `Signature: ${signatureMarkup}${logo}`; +} + /** * @param {{ name: string, designation: string, signatureDataUrl?: string } | null} signatory * @param {string} dateLabel @@ -91,12 +110,12 @@ function crestBlock(signatory, dateLabel) { const image = signatory.signatureDataUrl ? `CREST signature` - : ''; + : '___________________'; return [ `Name: ${filled(signatory.name)}`, `Designation: ${filled(signatory.designation)}`, - `Signature: ${image}`, + signatureRow(image, { withLogo: true }), `Date: ${filled(dateLabel)}`, ].join('\n\n'); } @@ -287,7 +306,10 @@ export function agreementHtml(options) { .filled { font-weight: 700; text-decoration: underline; text-underline-offset: 0.15em; } .watermark { position: fixed; top: 45%; left: -10%; width: 120%; text-align: center; font-size: 42pt; font-weight: 700; letter-spacing: 0.04em; color: rgba(185, 28, 28, 0.18); transform: rotate(-32deg); z-index: 10; } .typed-signature { font-family: "Segoe Script", "Apple Chancery", cursive; font-size: 20pt; } - img.signature { height: 64px; } + .signature-row { display: flex; justify-content: space-between; align-items: center; gap: 1rem; width: 100%; } + .signature-field { display: inline-flex; align-items: center; gap: 0.5rem; min-height: 64px; } + img.signature { height: 64px; max-width: 240px; width: auto; vertical-align: middle; background: #fff; } + img.crest-logo { height: 52px; width: auto; flex: none; } diff --git a/backend/src/pdf/render.js b/backend/src/pdf/render.js index ddd8c2a..e248da3 100644 --- a/backend/src/pdf/render.js +++ b/backend/src/pdf/render.js @@ -37,6 +37,44 @@ function binary() { return env.WEASYPRINT_BIN || 'weasyprint'; } +/** + * Write data-URI images next to the HTML so WeasyPrint loads them as local files. + * @param {string} html + * @param {string} directory + * @returns {Promise} + */ +async function materializeDataUriImages(html, directory) { + /** @type {Promise[]} */ + const writes = []; + let index = 0; + + const rewritten = html.replace( + /src="(data:image\/([a-z0-9+.-]+);base64,[A-Za-z0-9+/]+=*)"/gi, + (_full, dataUrl, subtype) => { + const normalized = String(subtype).toLowerCase(); + const extension = + normalized === 'jpeg' || normalized === 'jpg' + ? 'jpg' + : normalized === 'svg+xml' + ? 'svg' + : normalized.split('+')[0] || 'bin'; + const match = /^data:image\/[^;]+;base64,(.+)$/i.exec(dataUrl); + + if (!match) { + return _full; + } + + const filename = `embed-${index}.${extension}`; + index += 1; + writes.push(writeFile(path.join(directory, filename), Buffer.from(match[1], 'base64'))); + return `src="${filename}"`; + }, + ); + + await Promise.all(writes); + return rewritten; +} + /** * @param {string} htmlPath * @param {string} pdfPath @@ -46,6 +84,7 @@ function runWeasyPrint(htmlPath, pdfPath) { let settled = false; const child = spawn(binary(), [htmlPath, pdfPath], { stdio: ['ignore', 'ignore', 'pipe'], + cwd: path.dirname(htmlPath), }); let stderr = ''; @@ -96,7 +135,8 @@ export async function renderPdf(html) { try { const htmlPath = path.join(directory, 'agreement.html'); const pdfPath = path.join(directory, 'agreement.pdf'); - await writeFile(htmlPath, html, 'utf8'); + const prepared = await materializeDataUriImages(html, directory); + await writeFile(htmlPath, prepared, 'utf8'); await runWeasyPrint(htmlPath, pdfPath); return await readFile(pdfPath); } finally { diff --git a/backend/src/routes/applications.js b/backend/src/routes/applications.js index 86f636b..cde848c 100644 --- a/backend/src/routes/applications.js +++ b/backend/src/routes/applications.js @@ -57,13 +57,21 @@ export const applicationRoutes = { server.route({ method: 'PUT', path: '/api/applications/me', - options: { plugins: rateLimit }, + options: { + plugins: rateLimit, + payload: { + // Cancelled-cheque images are base64 data URLs (5MB file ≈ ~6.7MB encoded). + maxBytes: 8 * 1024 * 1024, + }, + }, async handler(request) { const application = await applicationFromRequest(request); const payload = request.payload && typeof request.payload === 'object' ? request.payload : {}; const finalize = request.query.finalize === 'true' || request.query.finalize === true; - return saveApplication(request, application, payload, finalize); + const saved = await saveApplication(request, application, payload, finalize); + // Same shape as GET so cancelledChequeUrl (and PDF urls) are available immediately. + return presentWithPdf(saved); }, }); diff --git a/backend/src/routes/consent.js b/backend/src/routes/consent.js new file mode 100644 index 0000000..e857952 --- /dev/null +++ b/backend/src/routes/consent.js @@ -0,0 +1,37 @@ +import { requireUser } from '../middleware/requireUser.js'; +import { getConsentForUser, submitConsent } from '../services/consent.js'; + +/** @type {import('@hapi/hapi').Plugin} */ +export const consentRoutes = { + name: 'consent-routes', + register(server) { + server.route({ + method: 'GET', + path: '/api/consent/me', + options: { + auth: false, + pre: [requireUser], + }, + handler(request) { + return getConsentForUser(Number(request.pre.user.id)); + }, + }); + + server.route({ + method: 'POST', + path: '/api/consent', + options: { + auth: false, + pre: [requireUser], + plugins: { + 'hapi-rate-limit': { + pathLimit: 30, + }, + }, + }, + handler(request) { + return submitConsent(request, Number(request.pre.user.id), request.payload); + }, + }); + }, +}; diff --git a/backend/src/routes/index.js b/backend/src/routes/index.js index 8514021..76e6906 100644 --- a/backend/src/routes/index.js +++ b/backend/src/routes/index.js @@ -2,9 +2,11 @@ import { env } from '../config/env.js'; import { adminRoutes } from './admin.js'; import { adminAuthRoutes } from './adminAuth.js'; import { applicationRoutes } from './applications.js'; +import { consentRoutes } from './consent.js'; import { devFileRoutes } from './devFiles.js'; import { healthRoutes } from './health.js'; import { surveyRoutes } from './survey.js'; +import { userAuthRoutes } from './userAuth.js'; import { webhookRoutes } from './webhooks.js'; /** @@ -13,6 +15,8 @@ import { webhookRoutes } from './webhooks.js'; export async function registerRoutes(server) { await server.register(healthRoutes); await server.register(adminAuthRoutes); + await server.register(userAuthRoutes); + await server.register(consentRoutes); await server.register(adminRoutes); await server.register(applicationRoutes); await server.register(surveyRoutes); diff --git a/backend/src/routes/survey.js b/backend/src/routes/survey.js index ae32b2a..b62e643 100644 --- a/backend/src/routes/survey.js +++ b/backend/src/routes/survey.js @@ -1,6 +1,12 @@ import Boom from '@hapi/boom'; import { z } from 'zod'; -import { getSurvey, saveSurveyProgress, submitSurvey } from '../services/survey.js'; +import { requireUser } from '../middleware/requireUser.js'; +import { + getSurvey, + issueSurveyLinkForUser, + saveSurveyProgress, + submitSurvey, +} from '../services/survey.js'; const answersSchema = z.object({ answers: z.record(z.string().max(80), z.unknown()), @@ -36,6 +42,19 @@ function answersFrom(payload) { export const surveyRoutes = { name: 'survey-routes', register(server) { + server.route({ + method: 'POST', + path: '/api/survey/me/link', + options: { + auth: false, + pre: [requireUser], + plugins: submitLimit, + }, + handler(request) { + return issueSurveyLinkForUser(Number(request.pre.user.id)); + }, + }); + server.route({ method: 'GET', path: '/api/survey/{token}', diff --git a/backend/src/routes/userAuth.js b/backend/src/routes/userAuth.js new file mode 100644 index 0000000..99d6cfe --- /dev/null +++ b/backend/src/routes/userAuth.js @@ -0,0 +1,118 @@ +import Boom from '@hapi/boom'; +import { z } from 'zod'; +import { auditFromRequest } from '../middleware/audit.js'; +import { requireUser } from '../middleware/requireUser.js'; +import { + getMe, + requestLoginOtp, + signUserToken, + verifyLoginOtp, +} from '../services/userAuth.js'; + +const emailSchema = z.object({ + email: z.string().email(), +}); + +const verifySchema = z.object({ + email: z.string().email(), + otp: z.string().min(4).max(8), +}); + +const otpLimit = { + 'hapi-rate-limit': { + pathLimit: 10, + userPathLimit: 10, + }, +}; + +/** @type {import('@hapi/hapi').Plugin} */ +export const userAuthRoutes = { + name: 'user-auth-routes', + register(server) { + server.route({ + method: 'POST', + path: '/api/auth/otp/request', + options: { + auth: false, + plugins: otpLimit, + }, + async handler(request) { + const parsed = emailSchema.safeParse(request.payload); + + if (!parsed.success) { + throw Boom.badRequest('A valid email is required'); + } + + return requestLoginOtp({ + email: parsed.data.email, + ip: request.info.remoteAddress, + }); + }, + }); + + server.route({ + method: 'POST', + path: '/api/auth/otp/verify', + options: { + auth: false, + plugins: otpLimit, + }, + async handler(request, h) { + const parsed = verifySchema.safeParse(request.payload); + + if (!parsed.success) { + throw Boom.badRequest('A valid email and code are required'); + } + + const result = await verifyLoginOtp({ + email: parsed.data.email, + otp: parsed.data.otp, + ip: request.info.remoteAddress, + }); + + const me = await getMe(Number(result.user.id)); + + return h + .response({ + user: result.user, + me, + }) + .state('user_token', { token: signUserToken(result.user.id) }); + }, + }); + + server.route({ + method: 'POST', + path: '/api/auth/logout', + options: { + auth: false, + pre: [requireUser], + }, + async handler(request, h) { + const user = request.pre.user; + + await auditFromRequest(request, { + actorType: 'PHYSICIAN', + actorId: user.id, + action: 'USER_LOGOUT', + entityType: 'users', + entityId: user.id, + }); + + return h.response({ ok: true }).unstate('user_token'); + }, + }); + + server.route({ + method: 'GET', + path: '/api/me', + options: { + auth: false, + pre: [requireUser], + }, + handler(request) { + return getMe(Number(request.pre.user.id)); + }, + }); + }, +}; diff --git a/backend/src/schemas/application.js b/backend/src/schemas/application.js index 72011bc..f8ee534 100644 --- a/backend/src/schemas/application.js +++ b/backend/src/schemas/application.js @@ -59,6 +59,8 @@ export const draftApplicationSchema = z account_number: optionalText(text(z.string().regex(accountPattern))), ifsc: optionalText(text(z.string().regex(ifscPattern))), accepted: z.boolean().optional(), + /** Optional cancelled-cheque image as a data URL, or null to remove. */ + cancelled_cheque: z.union([z.string().min(1), z.null()]).optional(), }) .strict(); diff --git a/backend/src/schemas/consent.js b/backend/src/schemas/consent.js new file mode 100644 index 0000000..8fc41a8 --- /dev/null +++ b/backend/src/schemas/consent.js @@ -0,0 +1,36 @@ +import { z } from 'zod'; + +export const CONSENT_ANSWER_KEYS = [ + 'voluntary_withdraw', + 'mou_required', + 'eligibility_six_months', + 'confidential_research', + 'web_questionnaire', + 'no_intervention', + 'aggregated_reporting', + 'honorarium', + 'agree_participate', +]; + +const requiredTrue = z.literal(true, { + errorMap: () => ({ message: 'This confirmation is required' }), +}); + +export const submitConsentSchema = z.object({ + participant_name: z.string().trim().min(1, 'Enter your name'), + consent_date: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/, 'Enter a valid date') + .refine((value) => !Number.isNaN(Date.parse(value)), 'Enter a valid date'), + answers: z.object({ + voluntary_withdraw: requiredTrue, + mou_required: requiredTrue, + eligibility_six_months: requiredTrue, + confidential_research: requiredTrue, + web_questionnaire: requiredTrue, + no_intervention: requiredTrue, + aggregated_reporting: requiredTrue, + honorarium: requiredTrue, + agree_participate: requiredTrue, + }), +}); diff --git a/backend/src/services/admin.js b/backend/src/services/admin.js index 8046a56..9098f8f 100644 --- a/backend/src/services/admin.js +++ b/backend/src/services/admin.js @@ -1,12 +1,10 @@ -import { createHash } from 'node:crypto'; import Boom from '@hapi/boom'; import argon2 from 'argon2'; import { z } from 'zod'; import { db } from '../db/knex.js'; import { auditFromRequest } from '../middleware/audit.js'; -import { agreementHtml } from '../pdf/agreementHtml.js'; -import { renderPdf } from '../pdf/render.js'; import { recordAudit } from './audit.js'; +import { finalizeSubmittedApplication } from './applicationApproval.js'; import { presentApplication } from './applications.js'; import { maskSecret, panLookup } from './fieldCrypto.js'; import { readPngDataUrl } from './png.js'; @@ -18,7 +16,6 @@ import { generateToken, hashToken } from './tokens.js'; const SIGNATURE_KEY = 'settings/signatory-signature.png'; const SIGNATURE_LIMIT = 500 * 1024; const STATUSES = ['DRAFT', 'SUBMITTED', 'APPROVED', 'REJECTED']; -const MISSING_SIGNATURE = 'Upload the CREST signatory signature in Settings before approving.'; const blankToUndefined = (value) => (value === '' ? undefined : value); @@ -119,13 +116,6 @@ function startOfWeek() { return date; } -function todayIso() { - const now = new Date(); - const month = String(now.getMonth() + 1).padStart(2, '0'); - const day = String(now.getDate()).padStart(2, '0'); - return `${now.getFullYear()}-${month}-${day}`; -} - /** * @param {string} image */ @@ -323,31 +313,6 @@ export async function getApplication(id) { }; } -/** - * @param {Record} application - */ -async function physicianSignature(application) { - const signature = await db('signatures') - .where({ application_id: application.id }) - .whereNull('voided_at') - .orderBy('id', 'desc') - .first(); - - if (!signature) { - return null; - } - - if (signature.type === 'DRAWN' && signature.image_key) { - const bytes = await getObject(signature.image_key); - return { - type: 'DRAWN', - imageDataUrl: `data:image/png;base64,${Buffer.from(bytes).toString('base64')}`, - }; - } - - return { type: 'TYPED', typedName: signature.typed_text }; -} - /** * @param {import('@hapi/hapi').Request} request * @param {number} id @@ -355,94 +320,11 @@ async function physicianSignature(application) { export async function approveApplication(request, id) { const application = await applicationById(id); - if (application.status !== 'SUBMITTED') { - throw Boom.conflict('Only a submitted application can be approved'); - } - - const signatory = await getSignatory(); - - if (!signatory.signatureKey) { - throw Boom.badRequest(MISSING_SIGNATURE); - } - - let signatureBytes; - - try { - signatureBytes = await getObject(signatory.signatureKey); - } catch { - throw Boom.badRequest(MISSING_SIGNATURE); - } - - const presented = presentApplication(application); - presented.status = 'APPROVED'; - const pdf = await renderPdf( - agreementHtml({ - application: presented, - signatory: { - name: signatory.name, - designation: signatory.designation, - signatureDataUrl: `data:image/png;base64,${Buffer.from(signatureBytes).toString('base64')}`, - }, - physicianSignature: await physicianSignature(application), - draft: false, - crestDate: todayIso(), - }), - ); - const sha256 = createHash('sha256').update(pdf).digest('hex'); - const pdfKey = `applications/${application.public_id}/v${application.version}/final.pdf`; - const token = generateToken(); - let stored = false; - - try { - await putObject(pdfKey, pdf, 'application/pdf'); - stored = true; - const decidedAt = new Date(); - await db.transaction(async (trx) => { - const current = await trx('applications').where({ id }).forUpdate().first(); - - if (!current || current.status !== 'SUBMITTED') { - throw Boom.conflict('Only a submitted application can be approved'); - } - - await trx('applications').where({ id }).update({ - status: 'APPROVED', - decided_at: decidedAt, - decided_by: request.pre.admin.id, - final_pdf_key: pdfKey, - signed_pdf_sha256: sha256, - }); - await trx('survey_tokens').insert({ - application_id: id, - token_hash: hashToken(token), - created_at: decidedAt, - }); - await recordAudit( - { - actorType: 'ADMIN', - actorId: request.pre.admin.id, - action: 'APPLICATION_APPROVED', - entityType: 'applications', - entityId: id, - ip: request.info.remoteAddress, - metadata: { sha256 }, - }, - trx, - ); - }); - } catch (error) { - if (stored) { - await deleteObject(pdfKey).catch(() => {}); - } - - throw error; - } - - await sendApprovedEmail({ - applicationId: id, - toEmail: application.email, - name: application.full_name, - token, - pdfKey, + await finalizeSubmittedApplication(request, application, { + decidedBy: request.pre.admin.id, + actorType: 'ADMIN', + actorId: request.pre.admin.id, + action: 'APPLICATION_APPROVED', }); return getApplication(id); diff --git a/backend/src/services/agreementDocuments.js b/backend/src/services/agreementDocuments.js index 7093307..4b4b01c 100644 --- a/backend/src/services/agreementDocuments.js +++ b/backend/src/services/agreementDocuments.js @@ -7,6 +7,7 @@ import { agreementHtml } from '../pdf/agreementHtml.js'; import { renderPdf } from '../pdf/render.js'; import { readPngDataUrl } from './png.js'; import { recordAudit } from './audit.js'; +import { finalizeSubmittedApplication } from './applicationApproval.js'; import { applicationFromRequest, assertCompleteApplication, @@ -36,9 +37,25 @@ const signSchema = z.discriminatedUnion('type', [ */ export async function presentWithPdf(application) { const body = presentApplication(application); + const finalKey = application.final_pdf_key ? String(application.final_pdf_key) : null; + const signedKey = application.signed_pdf_key ? String(application.signed_pdf_key) : null; - if (application.signed_pdf_key) { - body.signedPdfUrl = await getPresignedUrl(String(application.signed_pdf_key)); + if (signedKey) { + body.signedPdfUrl = await getPresignedUrl(signedKey); + } + + if (finalKey) { + body.finalPdfUrl = await getPresignedUrl(finalKey); + } + + // Prefer the final CREST-signed PDF for previews (same as admin). + const previewKey = finalKey || signedKey; + if (previewKey) { + body.pdfUrl = previewKey === finalKey ? body.finalPdfUrl : body.signedPdfUrl; + } + + if (application.cancelled_cheque_key) { + body.cancelledChequeUrl = await getPresignedUrl(String(application.cancelled_cheque_key)); } return body; @@ -202,8 +219,22 @@ export async function signApplication(request) { } const saved = await db('applications').where({ id: application.id }).first(); - await notifySubmission(saved); - return presentWithPdf(saved); + + try { + const finalized = await finalizeSubmittedApplication(request, saved, { + decidedBy: null, + actorType: 'SYSTEM', + actorId: null, + action: 'APPLICATION_AUTO_APPROVED', + }); + const body = await presentWithPdf(finalized.application); + body.surveyToken = finalized.surveyToken; + return body; + } catch (error) { + request.log(['warn', 'auto-approve'], error); + await notifySubmission(saved); + return presentWithPdf(saved); + } } /** diff --git a/backend/src/services/applicationApproval.js b/backend/src/services/applicationApproval.js new file mode 100644 index 0000000..6793f5e --- /dev/null +++ b/backend/src/services/applicationApproval.js @@ -0,0 +1,156 @@ +import { createHash } from 'node:crypto'; +import Boom from '@hapi/boom'; +import { db } from '../db/knex.js'; +import { agreementHtml } from '../pdf/agreementHtml.js'; +import { renderPdf } from '../pdf/render.js'; +import { recordAudit } from './audit.js'; +import { presentApplication } from './applications.js'; +import { sendApprovedEmail } from './mail.js'; +import { getSignatory } from './settings.js'; +import { deleteObject, getObject, putObject } from './storage.js'; +import { generateToken, hashToken } from './tokens.js'; + +export const MISSING_CREST_SIGNATURE = + 'Upload the CREST signatory signature in Settings before approving.'; + +function todayIso() { + const now = new Date(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const day = String(now.getDate()).padStart(2, '0'); + return `${now.getFullYear()}-${month}-${day}`; +} + +/** + * @param {Record} application + */ +export async function physicianSignatureForApproval(application) { + const signature = await db('signatures') + .where({ application_id: application.id }) + .whereNull('voided_at') + .orderBy('id', 'desc') + .first(); + + if (!signature) { + return null; + } + + if (signature.type === 'DRAWN' && signature.image_key) { + const bytes = await getObject(signature.image_key); + return { + type: 'DRAWN', + imageDataUrl: `data:image/png;base64,${Buffer.from(bytes).toString('base64')}`, + }; + } + + return { type: 'TYPED', typedName: signature.typed_text }; +} + +/** + * Finalize a SUBMITTED application: CREST-signed final PDF, APPROVED status, survey token, email. + * + * @param {import('@hapi/hapi').Request} request + * @param {Record} application + * @param {{ + * decidedBy?: number | null, + * actorType: string, + * actorId?: number | null, + * action?: string, + * }} options + */ +export async function finalizeSubmittedApplication(request, application, options) { + if (application.status !== 'SUBMITTED') { + throw Boom.conflict('Only a submitted application can be approved'); + } + + const signatory = await getSignatory(); + + if (!signatory.signatureKey) { + throw Boom.badRequest(MISSING_CREST_SIGNATURE); + } + + let signatureBytes; + + try { + signatureBytes = await getObject(signatory.signatureKey); + } catch { + throw Boom.badRequest(MISSING_CREST_SIGNATURE); + } + + const id = Number(application.id); + const presented = presentApplication(application); + presented.status = 'APPROVED'; + const pdf = await renderPdf( + agreementHtml({ + application: presented, + signatory: { + name: signatory.name, + designation: signatory.designation, + signatureDataUrl: `data:image/png;base64,${Buffer.from(signatureBytes).toString('base64')}`, + }, + physicianSignature: await physicianSignatureForApproval(application), + draft: false, + crestDate: todayIso(), + }), + ); + const sha256 = createHash('sha256').update(pdf).digest('hex'); + const pdfKey = `applications/${application.public_id}/v${application.version}/final.pdf`; + const token = generateToken(); + let stored = false; + + try { + await putObject(pdfKey, pdf, 'application/pdf'); + stored = true; + const decidedAt = new Date(); + await db.transaction(async (trx) => { + const current = await trx('applications').where({ id }).forUpdate().first(); + + if (!current || current.status !== 'SUBMITTED') { + throw Boom.conflict('Only a submitted application can be approved'); + } + + await trx('applications').where({ id }).update({ + status: 'APPROVED', + decided_at: decidedAt, + decided_by: options.decidedBy ?? null, + final_pdf_key: pdfKey, + signed_pdf_sha256: sha256, + }); + await trx('survey_tokens').insert({ + application_id: id, + token_hash: hashToken(token), + created_at: decidedAt, + }); + await recordAudit( + { + actorType: options.actorType, + actorId: options.actorId ?? null, + action: options.action ?? 'APPLICATION_APPROVED', + entityType: 'applications', + entityId: id, + ip: request.info.remoteAddress, + metadata: { sha256, auto: !options.decidedBy }, + }, + trx, + ); + }); + } catch (error) { + if (stored) { + await deleteObject(pdfKey).catch(() => {}); + } + + throw error; + } + + await sendApprovedEmail({ + applicationId: id, + toEmail: String(application.email), + name: application.full_name ? String(application.full_name) : null, + token, + pdfKey, + }); + + return { + application: await db('applications').where({ id }).first(), + surveyToken: token, + }; +} diff --git a/backend/src/services/applications.js b/backend/src/services/applications.js index 9a33ac8..e848785 100644 --- a/backend/src/services/applications.js +++ b/backend/src/services/applications.js @@ -4,8 +4,13 @@ import { db } from '../db/knex.js'; import { auditFromRequest } from '../middleware/audit.js'; import { draftApplicationSchema, finalizeApplicationSchema } from '../schemas/application.js'; import { decryptField, encryptField, panLookup } from './fieldCrypto.js'; +import { readImageDataUrl } from './png.js'; +import { deleteObject, putObject } from './storage.js'; import { generateToken, hashToken } from './tokens.js'; import { sendResumeLink } from './mail.js'; +import { readUserId } from './userAuth.js'; + +const CHEQUE_LIMIT = 5 * 1024 * 1024; const CODE_FIELDS = ['pan', 'ifsc', 'gstin']; @@ -79,14 +84,50 @@ function zodMessage(error) { } /** + * @param {unknown} value + * @returns {string | null} + */ +function jwtFromCookie(value) { + if (typeof value === 'string') { + return value; + } + + if (value && typeof value === 'object' && 'token' in value && typeof value.token === 'string') { + return value.token; + } + + return null; +} + +/** + * Resolve the physician application from a logged-in user cookie or a resume Bearer token. * @param {import('@hapi/hapi').Request} request */ export async function applicationFromRequest(request) { + const userJwt = jwtFromCookie(request.state.user_token); + const userId = userJwt ? readUserId(userJwt) : null; + + if (userId) { + const user = await db('users').where({ id: Number(userId), is_active: true }).first(); + + if (!user) { + throw Boom.unauthorized('Authentication required'); + } + + const application = await db('applications').where({ user_id: user.id }).first(); + + if (!application) { + throw Boom.badRequest('No application is linked to this account'); + } + + return application; + } + const header = request.headers.authorization ?? ''; const match = header.match(/^Bearer\s+(\S+)$/i); if (!match) { - throw Boom.unauthorized('This resume link is invalid or has expired'); + throw Boom.unauthorized('Authentication required'); } const tokenRow = await db('resume_tokens') @@ -186,10 +227,13 @@ function protectSecrets(updates) { } /** + * Persist draft/finalize updates and return the updated application row. + * Callers that need download URLs should wrap with presentWithPdf. * @param {import('@hapi/hapi').Request} request * @param {Record} application * @param {Record} payload * @param {boolean} finalize + * @returns {Promise>} */ export async function saveApplication(request, application, payload, finalize) { if (application.status === 'SUBMITTED' || application.status === 'APPROVED') { @@ -204,13 +248,36 @@ export async function saveApplication(request, application, payload, finalize) { /** @type {Record} */ const updates = {}; + const chequePayload = parsed.data.cancelled_cheque; for (const [key, value] of Object.entries(parsed.data)) { - if (value !== undefined) { + if (value !== undefined && key !== 'cancelled_cheque') { updates[key] = value; } } + if (chequePayload === null) { + if (application.cancelled_cheque_key) { + await deleteObject(String(application.cancelled_cheque_key)).catch(() => {}); + } + updates.cancelled_cheque_key = null; + } else if (typeof chequePayload === 'string') { + const image = readImageDataUrl( + chequePayload, + CHEQUE_LIMIT, + 'The cancelled cheque image must be 5MB or smaller', + ); + const key = `applications/${application.public_id}/v${application.version}/cancelled-cheque.${image.extension}`; + await putObject(key, image.buffer, image.contentType); + if ( + application.cancelled_cheque_key && + String(application.cancelled_cheque_key) !== key + ) { + await deleteObject(String(application.cancelled_cheque_key)).catch(() => {}); + } + updates.cancelled_cheque_key = key; + } + if (finalize) { const merged = { ...presentApplication(application), @@ -284,6 +351,5 @@ export async function saveApplication(request, application, payload, finalize) { metadata: { finalize }, }); - const saved = await db('applications').where({ id: application.id }).first(); - return presentApplication(saved); + return db('applications').where({ id: application.id }).first(); } diff --git a/backend/src/services/consent.js b/backend/src/services/consent.js new file mode 100644 index 0000000..93bd4d7 --- /dev/null +++ b/backend/src/services/consent.js @@ -0,0 +1,94 @@ +import Boom from '@hapi/boom'; +import { db } from '../db/knex.js'; +import { auditFromRequest } from '../middleware/audit.js'; +import { submitConsentSchema } from '../schemas/consent.js'; + +/** + * @param {unknown} value + */ +function parseJson(value) { + if (typeof value === 'string') { + return JSON.parse(value); + } + + return value ?? null; +} + +/** + * @param {Record} row + */ +export function presentConsent(row) { + return { + id: row.id, + participant_name: row.participant_name, + consent_date: + typeof row.consent_date === 'string' + ? row.consent_date.slice(0, 10) + : row.consent_date + ? new Date(row.consent_date).toISOString().slice(0, 10) + : null, + answers: parseJson(row.answers), + submitted_at: row.submitted_at, + }; +} + +/** + * @param {number} userId + */ +export async function getConsentForUser(userId) { + const row = await db('consents').where({ user_id: userId }).first(); + return row ? presentConsent(row) : null; +} + +/** + * @param {import('@hapi/hapi').Request} request + * @param {number} userId + * @param {unknown} payload + */ +export async function submitConsent(request, userId, payload) { + const existing = await db('consents').where({ user_id: userId }).first(); + + if (existing) { + throw Boom.conflict('Consent has already been submitted'); + } + + const application = await db('applications').where({ user_id: userId }).first(); + + if (!application) { + throw Boom.badRequest('No application is linked to this account'); + } + + const parsed = submitConsentSchema.safeParse(payload); + + if (!parsed.success) { + const message = parsed.error.issues + .map((issue) => `${issue.path.join('.') || 'body'}: ${issue.message}`) + .join('; '); + throw Boom.badRequest(message); + } + + const now = new Date(); + const [id] = await db('consents').insert({ + user_id: userId, + application_id: application.id, + participant_name: parsed.data.participant_name, + consent_date: parsed.data.consent_date, + answers: JSON.stringify(parsed.data.answers), + submitted_at: now, + }); + + await db('users').where({ id: userId }).update({ + name: parsed.data.participant_name, + }); + + await auditFromRequest(request, { + actorType: 'PHYSICIAN', + actorId: userId, + action: 'CONSENT_SUBMITTED', + entityType: 'consents', + entityId: id, + }); + + const row = await db('consents').where({ id }).first(); + return presentConsent(row); +} diff --git a/backend/src/services/emailTemplates.js b/backend/src/services/emailTemplates.js index 177ce4f..7a49c1b 100644 --- a/backend/src/services/emailTemplates.js +++ b/backend/src/services/emailTemplates.js @@ -53,11 +53,23 @@ function layout(content) { /** * @param {string} name - * @param {{ url?: string, name?: string, reason?: string }} data + * @param {{ url?: string, name?: string, reason?: string, otp?: string }} data */ export function renderEmail(name, data) { const physician = data.name || 'Doctor'; + if (name === 'login_otp') { + return layout({ + subject: 'Your sign-in code', + heading: 'Your sign-in code', + paragraphs: [ + 'Use this one-time code to sign in to the Adherence Pack Study. It expires in 10 minutes.', + data.otp || '', + 'If you did not request this code, you can ignore this email.', + ], + }); + } + if (name === 'resume_link') { return layout({ subject: 'Continue your agreement', diff --git a/backend/src/services/mail.js b/backend/src/services/mail.js index 05b0089..19c56fd 100644 --- a/backend/src/services/mail.js +++ b/backend/src/services/mail.js @@ -5,7 +5,7 @@ import { renderEmail } from './emailTemplates.js'; import { getMailFromName } from './settings.js'; import { getObject } from './storage.js'; -/** @type {{ toEmail: string, url: string, applicationId: number, template?: string }[]} */ +/** @type {{ toEmail: string, url: string, applicationId: number, template?: string, otp?: string }[]} */ export const outbox = []; export function clearOutbox() { @@ -102,7 +102,7 @@ async function deliverToSendGrid(message) { * Send one template. Failures are stored on email_log and are not rethrown. * @param {string} name * @param {string} to - * @param {{ applicationId: number, url?: string, name?: string, reason?: string, pdfKey?: string | null, reminderCount?: number }} data + * @param {{ applicationId: number, url?: string, name?: string, reason?: string, otp?: string, pdfKey?: string | null, reminderCount?: number }} data */ export async function sendTemplate(name, to, data) { /** @type {number | undefined} */ @@ -137,6 +137,7 @@ export async function sendTemplate(name, to, data) { url: data.url ?? '', applicationId: data.applicationId, template: name, + otp: data.otp, }); } @@ -187,6 +188,16 @@ export async function sendTemplate(name, to, data) { } } +/** + * @param {{ applicationId: number, toEmail: string, otp: string }} message + */ +export async function sendLoginOtp(message) { + await sendTemplate('login_otp', message.toEmail, { + applicationId: message.applicationId, + otp: message.otp, + }); +} + /** * @param {{ applicationId: number, toEmail: string, token: string }} message */ diff --git a/backend/src/services/png.js b/backend/src/services/png.js index 2bcac93..acbebd4 100644 --- a/backend/src/services/png.js +++ b/backend/src/services/png.js @@ -42,3 +42,41 @@ export function readPngDataUrl(image, maxBytes, sizeMessage) { return buffer; } + +/** + * Decode a PNG or JPEG data URL for optional document uploads (e.g. cancelled cheque). + * @param {string} image + * @param {number} maxBytes + * @param {string} sizeMessage + * @returns {{ buffer: Buffer, contentType: 'image/png' | 'image/jpeg', extension: 'png' | 'jpg' }} + */ +export function readImageDataUrl(image, maxBytes, sizeMessage) { + const match = /^data:image\/(png|jpeg);base64,([A-Za-z0-9+/]+={0,2})$/.exec(image); + + if (!match) { + throw Boom.badRequest('Upload a PNG or JPEG image'); + } + + const kind = /** @type {'png' | 'jpeg'} */ (match[1]); + const buffer = Buffer.from(match[2], 'base64'); + + if (buffer.length === 0 || buffer.length > maxBytes) { + throw Boom.badRequest(sizeMessage); + } + + if (kind === 'png') { + const png = buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47; + if (!png || buffer.length < 8) { + throw Boom.badRequest('Upload a PNG or JPEG image'); + } + + return { buffer, contentType: 'image/png', extension: 'png' }; + } + + const jpeg = buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff; + if (!jpeg) { + throw Boom.badRequest('Upload a PNG or JPEG image'); + } + + return { buffer, contentType: 'image/jpeg', extension: 'jpg' }; +} diff --git a/backend/src/services/storage.js b/backend/src/services/storage.js index b53d476..b9692a2 100644 --- a/backend/src/services/storage.js +++ b/backend/src/services/storage.js @@ -16,7 +16,7 @@ const LOCAL_ROOT = path.resolve( path.dirname(fileURLToPath(import.meta.url)), '../../.local-storage', ); -const URL_TTL_MS = 5 * 60 * 1000; +const URL_TTL_MS = 60 * 60 * 1000; /** @type {S3Client | undefined} */ let s3; diff --git a/backend/src/services/survey.js b/backend/src/services/survey.js index 3c72618..970bedc 100644 --- a/backend/src/services/survey.js +++ b/backend/src/services/survey.js @@ -1,7 +1,7 @@ import Boom from '@hapi/boom'; import { db } from '../db/knex.js'; import { recordAudit } from './audit.js'; -import { hashToken } from './tokens.js'; +import { generateToken, hashToken } from './tokens.js'; import { knownAnswers, validateAnswers } from '../survey/validate.js'; import { stripHtmlDeep } from './sanitize.js'; @@ -84,6 +84,46 @@ function present(definitionRow, response, application) { }; } +/** + * Issue (or rotate) a survey link for a logged-in physician whose agreement is approved. + * @param {number} userId + */ +export async function issueSurveyLinkForUser(userId) { + const application = await db('applications').where({ user_id: userId }).first(); + + if (!application) { + throw Boom.badRequest('No application is linked to this account'); + } + + if (application.status !== 'APPROVED') { + throw Boom.conflict('The survey is available after the agreement is approved'); + } + + const existing = await db('survey_responses').where({ application_id: application.id }).first(); + + if (existing?.status === 'COMPLETED') { + throw Boom.conflict('This survey has already been submitted'); + } + + const token = generateToken(); + const createdAt = new Date(); + + await db.transaction(async (trx) => { + await trx('survey_tokens') + .where({ application_id: application.id }) + .whereNull('used_at') + .whereNull('revoked_at') + .update({ revoked_at: createdAt }); + await trx('survey_tokens').insert({ + application_id: application.id, + token_hash: hashToken(token), + created_at: createdAt, + }); + }); + + return { token, url: `/survey/${token}` }; +} + /** * @param {string} token */ diff --git a/backend/src/services/userAuth.js b/backend/src/services/userAuth.js new file mode 100644 index 0000000..3c01668 --- /dev/null +++ b/backend/src/services/userAuth.js @@ -0,0 +1,316 @@ +import { randomInt, randomUUID } from 'node:crypto'; +import Boom from '@hapi/boom'; +import jwt from 'jsonwebtoken'; +import { db } from '../db/knex.js'; +import { env } from '../config/env.js'; +import { presentApplication } from './applications.js'; +import { recordAudit } from './audit.js'; +import { sendLoginOtp } from './mail.js'; +import { getPresignedUrl } from './storage.js'; +import { hashToken, verifyToken } from './tokens.js'; + +const OTP_TTL_MS = 10 * 60 * 1000; +const MAX_ATTEMPTS = 5; +const OTP_LENGTH = 6; + +/** + * @param {number|string} userId + * @returns {string} + */ +export function signUserToken(userId) { + if (!env.JWT_SECRET) { + throw new Error('JWT_SECRET is required'); + } + + return jwt.sign({ sub: String(userId), role: 'user' }, env.JWT_SECRET, { expiresIn: '8h' }); +} + +/** + * @param {string} token + * @returns {string | null} + */ +export function readUserId(token) { + if (!env.JWT_SECRET) { + return null; + } + + try { + const payload = jwt.verify(token, env.JWT_SECRET); + + if (typeof payload === 'string' || typeof payload.sub !== 'string') { + return null; + } + + if (payload.role !== 'user') { + return null; + } + + return payload.sub; + } catch { + return null; + } +} + +/** + * @param {string} email + */ +function normalizeEmail(email) { + return email.trim().toLowerCase(); +} + +/** + * @returns {string} + */ +function generateOtp() { + const max = 10 ** OTP_LENGTH; + return String(randomInt(0, max)).padStart(OTP_LENGTH, '0'); +} + +/** + * @param {string} email + */ +async function ensureUserAndApplication(email) { + let user = await db('users').where({ email }).first(); + + if (!user) { + const [id] = await db('users').insert({ + email, + is_active: true, + }); + user = await db('users').where({ id }).first(); + } + + if (!user?.is_active) { + throw Boom.forbidden('This account is inactive'); + } + + let application = await db('applications').where({ user_id: user.id }).first(); + + if (!application) { + application = await db('applications') + .where({ email, status: 'DRAFT' }) + .whereNull('user_id') + .orderBy('id', 'desc') + .first(); + + if (application) { + await db('applications').where({ id: application.id }).update({ user_id: user.id }); + application = await db('applications').where({ id: application.id }).first(); + } else { + const [id] = await db('applications').insert({ + public_id: randomUUID(), + email, + status: 'DRAFT', + user_id: user.id, + }); + application = await db('applications').where({ id }).first(); + } + } + + return { user, application }; +} + +/** + * @param {{ email: string, ip?: string | null }} input + */ +export async function requestLoginOtp(input) { + const email = normalizeEmail(input.email); + const { user, application } = await ensureUserAndApplication(email); + const code = generateOtp(); + const now = new Date(); + + await db('login_otps') + .where({ email }) + .whereNull('used_at') + .update({ used_at: now }); + + await db('login_otps').insert({ + email, + code_hash: hashToken(code), + expires_at: new Date(now.getTime() + OTP_TTL_MS), + attempts: 0, + created_at: now, + }); + + await sendLoginOtp({ + applicationId: Number(application.id), + toEmail: email, + otp: code, + }); + + await recordAudit({ + actorType: 'PHYSICIAN', + actorId: user.id, + action: 'USER_OTP_REQUESTED', + entityType: 'users', + entityId: user.id, + ip: input.ip, + }); + + if (env.NODE_ENV === 'development') { + console.info(`[dev] Login OTP for ${email}: ${code}`); + } + + return { + ok: true, + message: 'If that email can sign in, a one-time code has been sent.', + }; +} + +/** + * @param {{ email: string, otp: string, ip?: string | null }} input + */ +export async function verifyLoginOtp(input) { + const email = normalizeEmail(input.email); + const otp = String(input.otp ?? '').trim(); + + if (!/^\d{6}$/.test(otp)) { + throw Boom.unauthorized('Invalid or expired code'); + } + + const row = await db('login_otps') + .where({ email }) + .whereNull('used_at') + .orderBy('id', 'desc') + .first(); + + if (!row) { + throw Boom.unauthorized('Invalid or expired code'); + } + + if (new Date(row.expires_at).getTime() <= Date.now()) { + throw Boom.unauthorized('Invalid or expired code'); + } + + if (Number(row.attempts) >= MAX_ATTEMPTS) { + throw Boom.unauthorized('Invalid or expired code'); + } + + if (!verifyToken(otp, String(row.code_hash))) { + await db('login_otps') + .where({ id: row.id }) + .update({ attempts: Number(row.attempts) + 1 }); + throw Boom.unauthorized('Invalid or expired code'); + } + + const { user, application } = await ensureUserAndApplication(email); + const now = new Date(); + + await db('login_otps').where({ id: row.id }).update({ used_at: now }); + await db('users').where({ id: user.id }).update({ last_login_at: now }); + + await recordAudit({ + actorType: 'PHYSICIAN', + actorId: user.id, + action: 'USER_LOGIN', + entityType: 'users', + entityId: user.id, + ip: input.ip, + }); + + return { + ok: true, + user: presentUser(user), + applicationId: application.id, + }; +} + +/** + * @param {Record} user + */ +export function presentUser(user) { + return { + id: user.id, + email: user.email, + name: user.name ?? null, + }; +} + +/** + * @param {number} userId + */ +export async function getMe(userId) { + const user = await db('users').where({ id: userId }).first(); + + if (!user || !user.is_active) { + throw Boom.unauthorized('Authentication required'); + } + + const application = await db('applications').where({ user_id: user.id }).first(); + const consent = await db('consents').where({ user_id: user.id }).first(); + const survey = application + ? await db('survey_responses').where({ application_id: application.id }).first() + : null; + + const consentDone = Boolean(consent?.submitted_at); + const agreementDone = + application?.status === 'APPROVED' || Boolean(application?.final_pdf_key); + const surveyDone = survey?.status === 'COMPLETED'; + const surveyStatus = survey ? String(survey.status) : null; + + /** @type {'consent' | 'agreement' | 'survey' | 'dashboard'} */ + let nextStep = 'consent'; + + if (consentDone && !agreementDone) { + nextStep = 'agreement'; + } else if (consentDone && agreementDone && !surveyDone) { + nextStep = 'survey'; + } else if (consentDone && agreementDone && surveyDone) { + nextStep = 'dashboard'; + } + + /** @type {Record | null} */ + let applicationSummary = null; + + if (application) { + const presented = presentApplication(application); + applicationSummary = { + id: application.id, + public_id: application.public_id, + status: presented.status, + email: presented.email, + agreement_date: presented.agreement_date, + full_name: presented.full_name, + age: presented.age, + address: presented.address, + registration_no: presented.registration_no, + medical_council: presented.medical_council, + pan: presented.pan, + gstin: presented.gstin, + bank_name: presented.bank_name, + account_holder: presented.account_holder, + account_number: presented.account_number, + ifsc: presented.ifsc, + signedPdfUrl: application.signed_pdf_key + ? await getPresignedUrl(String(application.signed_pdf_key)) + : null, + finalPdfUrl: application.final_pdf_key + ? await getPresignedUrl(String(application.final_pdf_key)) + : null, + cancelledChequeUrl: application.cancelled_cheque_key + ? await getPresignedUrl(String(application.cancelled_cheque_key)) + : null, + }; + } + + return { + user: presentUser(user), + application: applicationSummary, + survey: survey + ? { + status: String(survey.status), + startedAt: survey.started_at ?? null, + completedAt: survey.completed_at ?? null, + } + : null, + steps: { + consent: { + done: consentDone, + completedAt: consent?.submitted_at ?? null, + }, + agreement: { done: agreementDone }, + survey: { done: surveyDone, status: surveyStatus }, + }, + nextStep, + }; +} diff --git a/backend/src/survey/definition.js b/backend/src/survey/definition.js index 99df775..6ec7137 100644 --- a/backend/src/survey/definition.js +++ b/backend/src/survey/definition.js @@ -1,11 +1,11 @@ export const SURVEY_TITLE = - "Physicians' Perspectives on Patient Adherence to Inhaled Maintenance Therapy"; + "Physicians' Perceptions of the Impact of an Extended-Dose Pack versus Regular Pack on Adherence to Inhaled Maintenance Medication in Asthma and COPD: A Cross-Sectional Survey"; const packRows = [ - { id: 'regular_pmdi', label: 'Regular pMDI' }, - { id: 'regular_rotacap', label: 'Regular Rotacap' }, - { id: 'extended_pmdi', label: 'Extended-Dose pMDI' }, - { id: 'extended_rotacap', label: 'Extended-Dose Rotacap' }, + { id: 'regular_pmdi', label: 'Regular Pack — Formoterol/Budesonide 200 & 400 mcg pMDI (120 doses)' }, + { id: 'regular_rotacap', label: 'Regular Pack — Rotacap (pack of 30)' }, + { id: 'extended_pmdi', label: 'Extended-Dose Pack — Formoterol/Budesonide 200 & 400 mcg pMDI (180 doses)' }, + { id: 'extended_rotacap', label: 'Extended-Dose Pack — Rotacap (pack of 60)' }, ]; const packColumns = [ @@ -21,17 +21,19 @@ const likertScale = [ { id: 'strongly_agree', label: 'Strongly Agree' }, ]; -/** Version 1 of the adherence pack questionnaire. */ +/** Survey Questionnaire Version 01 (14/10/2026). */ export const surveyDefinition = { + version: '01', + versionDate: '2026-10-14', sections: [ { id: 'demographics', - title: 'Basic Demographics / Doctor details', + title: 'Basic Demographics / Doctor Details', questions: [ { id: 'subtitle', type: 'help', - text: 'A Cross-Sectional Comparison of Extended-Dose and Standard Packaging', + text: SURVEY_TITLE, }, { id: 'name', type: 'short_text', label: 'Name', required: false }, { @@ -42,13 +44,11 @@ export const surveyDefinition = { other: true, options: [ { id: 'mbbs', label: 'MBBS' }, - { id: 'md_chest', label: 'MD Chest' }, - { id: 'dm_pulmo', label: 'DM Pulmo' }, - { id: 'dnb', label: 'DNB' }, - { id: 'd3ch', label: 'D3CH' }, - { id: 'md_med', label: 'MD Med' }, - { id: 'dnb_med', label: 'DNB Med' }, - { id: 'other', label: 'Any other, please specify' }, + { id: 'md_chest', label: 'MD (Chest)' }, + { id: 'dm_pulmo_dnb_dch', label: 'DM (Pulmo / DNB / DCH)' }, + { id: 'md_med', label: 'MD (Med)' }, + { id: 'dnb_med', label: 'DNB (Med)' }, + { id: 'other', label: 'Any other (specify)' }, ], }, { @@ -80,8 +80,7 @@ export const surveyDefinition = { options: [ { id: 'chest', label: 'Chest Physician' }, { id: 'consulting', label: 'Consulting Physician' }, - { id: 'general', label: 'General Physician' }, - { id: 'other', label: 'Other (please specify)' }, + { id: 'other', label: 'Other (specify)' }, ], }, { id: 'city', type: 'short_text', label: 'City', required: false }, @@ -90,12 +89,12 @@ export const surveyDefinition = { }, { id: 'practice', - title: 'Physician practice profile', + title: 'Physician Practice Profile', questions: [ { id: 'patients_per_month', type: 'number', - label: 'On an average how many patients do you see per month?', + label: 'On an average, how many patients do you see per month?', required: true, integer: true, min: 0, @@ -151,13 +150,13 @@ export const surveyDefinition = { }, { id: 'prescribing', - title: 'Foracort prescribing pattern', + title: 'Foracort Prescribing Pattern', questions: [ { id: 'asthma_formoterol_pct', type: 'percentage', label: - 'Approximately what proportion of your asthma patients receive Formoterol/Budesonide', + 'Approximately what proportion of your asthma patients receive Formoterol/Budesonide?', required: true, min: 0, max: 100, @@ -166,16 +165,28 @@ export const surveyDefinition = { id: 'copd_formoterol_pct', type: 'percentage', label: - 'Approximately what proportion of your COPD patients receive Formoterol/Budesonide', + 'Approximately what proportion of your COPD patients receive Formoterol/Budesonide?', required: true, min: 0, max: 100, }, + { + id: 'extended_dose_duration', + type: 'single_choice', + label: + 'For how long have you been prescribing the Extended-Dose Pack to your patients with asthma and/or COPD?', + required: true, + options: [ + { id: 'less_than_6_months', label: 'Less than 6 months' }, + { id: 'six_months_to_one_year', label: '6 months to less than 1 year' }, + { id: 'more_than_one_year', label: 'More than 1 year' }, + ], + }, { id: 'pack_intro', type: 'section_header', label: - 'Among your patients receiving Foracort, approximately what proportion do you prescribe:', + 'Among your patients receiving Foracort, approximately what proportion do you prescribe (each column must total 100%):', }, { id: 'pack_split', @@ -188,31 +199,26 @@ export const surveyDefinition = { max: 100, sumTo: 100, }, - { - id: 'pack_note_regular', - type: 'help', - text: '1: Regular Pack - Formoterol/Budesonide 200 & 400mcg - Rotacaps (pack of 30) & MDI (120 doses)', - }, - { - id: 'pack_note_extended', - type: 'help', - text: '2: Extended-Dose Pack - Formoterol/Budesonide 200 & 400 mcg - Rotacaps (pack of 60) & MDI (180 doses)', - }, { id: 'pack_note_cost', type: 'help', - text: 'Note: The unit dose cost for DPI & MDI remains same irrespective of the pack size', + text: 'Note: Unit dose cost is the same for Regular and Extended-Dose packs (pMDI 120 vs 180 doses; Rotacap pack of 30 vs 60).', }, ], }, { id: 'adherence', - title: 'Physician assessment of adherence', + title: 'Physician Assessment of Adherence', questions: [ { id: 'adherence_prompt', type: 'help', - text: 'Based on your clinical experience, approximately what proportion of patients are adherent to their prescribed treatment? (Please provide your estimate out of 100 patients)', + text: 'Based on your clinical experience, approximately what proportion of patients are adherent to their prescribed treatment? (Estimate out of 100 patients.)', + }, + { + id: 'adherence_definition', + type: 'help', + text: 'A patient is considered "adherent" if the physician believes the patient takes at least 75% of the prescribed inhaled maintenance medication doses during a typical month of treatment.', }, { id: 'adherence_estimate', @@ -230,7 +236,7 @@ export const surveyDefinition = { }, { id: 'comparison', - title: 'Physician assessment of adherence', + title: 'Physician Assessment of Adherence', questions: [ { id: 'adherence_comparison', @@ -251,13 +257,13 @@ export const surveyDefinition = { }, { id: 'barriers', - title: 'Reasons for non-adherence', + title: 'Reasons for Non-adherence', questions: [ { id: 'barriers', type: 'multi_choice', label: - 'In your opinion What are the key barriers to adherence to inhaled maintenance therapy of formoterol/budesonide in your asthma and COPD patients?', + 'In your opinion, what are the key barriers to adherence to inhaled maintenance therapy of formoterol/budesonide in your asthma and COPD patients?', required: true, other: true, options: [ @@ -276,27 +282,27 @@ export const surveyDefinition = { { id: 'remembering', label: 'Difficulty remembering to take doses' }, { id: 'technique', label: 'Inhaler technique difficulties' }, { id: 'lifestyle', label: 'Patient lifestyle/work-related factors' }, - { id: 'other', label: 'Other (Specify)' }, + { id: 'other', label: 'Other (specify)' }, ], }, ], }, { id: 'benefit', - title: 'Patient characteristics associated with adherence', + title: 'Patient Characteristics Associated with Adherence', questions: [ { id: 'benefit', type: 'multi_choice', label: - 'Which patients, in your opinion, are most likely to benefit from an Extended dose Pack?', + 'Which patients, in your opinion, are most likely to benefit from an Extended-dose pack?', required: true, other: true, options: [ { id: 'elderly', label: 'Elderly patients' }, { id: 'access', - label: 'Difficulty in accessing the pharmacy for a refill (e.g. Remote location)', + label: 'Difficulty accessing the pharmacy for a refill (e.g., remote location)', }, { id: 'exacerbations', label: 'Patients with frequent exacerbations' }, { id: 'forgetting', label: 'Patients frequently forgetting doses' }, @@ -306,7 +312,7 @@ export const surveyDefinition = { id: 'maintenance', label: 'All patients requiring long-term maintenance therapy', }, - { id: 'busy', label: 'Busy Lifestyle / working professional' }, + { id: 'busy', label: 'Busy lifestyle / working professional' }, { id: 'travellers', label: 'Frequent travellers' }, { id: 'caregiver', label: 'Caregiver-dependent patients' }, { id: 'other', label: 'Other' }, @@ -316,13 +322,13 @@ export const surveyDefinition = { }, { id: 'perspective', - title: 'Physician perspective on the Extended-Dose Pack', + title: 'Physician Perspective on the Extended-Dose Pack', questions: [ { id: 'perspective_intro', type: 'section_header', label: - 'What do you consider the main advantages / disadvantages of the Extended-Dose Pack compared to Regular Pack', + 'What do you consider the main advantages / disadvantages of the Extended-Dose Pack compared to the Regular Pack?', }, { id: 'advantages', type: 'long_text', label: 'Advantages', required: true }, { id: 'disadvantages', type: 'long_text', label: 'Disadvantages', required: true }, @@ -336,13 +342,13 @@ export const surveyDefinition = { id: 'likert_intro', type: 'section_header', label: - 'Overall, how would you rate the Extended-Dose Pack compared with the Regular Pack with respect to:', + 'Overall, how would you rate the Extended-Dose Pack compared with the Regular Pack with respect to the following?', }, { id: 'likert', type: 'likert_matrix', label: - 'Overall, how would you rate the Extended-Dose Pack compared with the Regular Pack with respect to:', + 'Overall, how would you rate the Extended-Dose Pack compared with the Regular Pack with respect to the following?', required: true, scale: likertScale, rows: [ diff --git a/backend/src/survey/seed.js b/backend/src/survey/seed.js index 03e82f5..68f3a88 100644 --- a/backend/src/survey/seed.js +++ b/backend/src/survey/seed.js @@ -1,20 +1,34 @@ import { db } from '../db/knex.js'; import { SURVEY_TITLE, surveyDefinition } from './definition.js'; +/** Questionnaire V01 (14/10/2026). */ +export const SURVEY_DEFINITION_VERSION = 2; + /** - * Insert version 1 when it is missing. Leave an existing version 1 unchanged. + * Ensure the active survey definition matches the current V01 questionnaire. + * Existing in-progress responses stay pinned to their original definition id. * @param {import('knex').Knex | import('knex').Knex.Transaction} [connection] */ export async function seedSurveyDefinition(connection = db) { - const existing = await connection('survey_definitions').where({ version: 1 }).first(); + const existing = await connection('survey_definitions') + .where({ version: SURVEY_DEFINITION_VERSION }) + .first(); if (existing) { + await connection('survey_definitions').where({ is_active: true }).update({ is_active: false }); + await connection('survey_definitions') + .where({ id: existing.id }) + .update({ + is_active: true, + title: SURVEY_TITLE, + definition: JSON.stringify(surveyDefinition), + }); return existing.id; } await connection('survey_definitions').where({ is_active: true }).update({ is_active: false }); const [id] = await connection('survey_definitions').insert({ - version: 1, + version: SURVEY_DEFINITION_VERSION, title: SURVEY_TITLE, definition: JSON.stringify(surveyDefinition), is_active: true, diff --git a/backend/test/agreementHtml.test.js b/backend/test/agreementHtml.test.js index 655f386..85d9bf0 100644 --- a/backend/test/agreementHtml.test.js +++ b/backend/test/agreementHtml.test.js @@ -25,12 +25,16 @@ describe('agreement HTML', () => { draft: true, }); - expect(html).toContain('is is executed'); + expect(html).toContain('is executed on this'); + expect(html).not.toContain('is is executed'); expect(html).toContain('

CONSULTATION AGREEMENT

'); expect(html).toContain('

BETWEEN

'); expect(html).toContain('

… PARTY OF THE FIRST PART

'); expect(html).toContain('

1. PURPOSE

'); - expect(html).toContain('WHEREAS, CREST Private Limited'); + expect(html).toContain( + 'WHEREAS, Chest Research And Training Private Limited', + ); + expect(html).toContain('Extended-Dose Pack versus Regular Pack'); expect(html).toContain('AND WHEREAS, the said study'); expect(html).not.toContain('1.1 CREST'); expect(html).toContain('.filled { font-weight: 700; text-decoration: underline;'); @@ -66,6 +70,8 @@ describe('agreement HTML', () => { expect(html).toContain('Dr Sudeep Salvi'); expect(html).toContain('Medical Director'); expect(html).toContain('data:image/png;base64,AAAA'); + expect(html).toContain('class="signature-row"'); + expect(html).toContain('alt="CREST"'); expect(html).not.toContain('Pending approval'); expect(html).not.toContain('DRAFT - NOT SIGNED'); }); diff --git a/backend/test/applications.test.js b/backend/test/applications.test.js index 42568f5..439b3ff 100644 --- a/backend/test/applications.test.js +++ b/backend/test/applications.test.js @@ -269,6 +269,11 @@ describe('applications', () => { }); expect(missingConsent.statusCode).toBe(400); + // Ensure auto-approve cannot run so this case stays SUBMITTED. + await db('settings') + .where({ key: 'signatory_signature_key' }) + .update({ value: JSON.stringify('') }); + const signed = await api.inject({ method: 'POST', url: '/api/applications/me/sign', @@ -277,6 +282,7 @@ describe('applications', () => { }); expect(signed.statusCode).toBe(200); + // Without a CREST signatory image, auto-approve fails and the row stays SUBMITTED. expect(signed.result).toMatchObject({ status: 'SUBMITTED' }); expect(signed.result.signedPdfUrl).toEqual(expect.any(String)); const row = await db('applications').where({ email }).first(); @@ -284,6 +290,48 @@ describe('applications', () => { expect(row.submitted_at).toBeTruthy(); }); + it('auto-approves on sign when the CREST signature is configured', async () => { + const { putObject } = await import('../src/services/storage.js'); + const api = await start(); + await postEmail(api); + const headers = { authorization: `Bearer ${tokenFrom(outbox[0].url)}` }; + + await api.inject({ + method: 'PUT', + url: '/api/applications/me?finalize=true', + headers, + payload: complete, + }); + + const png = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', + 'base64', + ); + await putObject('settings/signatory-signature.png', png, 'image/png'); + await db('settings') + .where({ key: 'signatory_signature_key' }) + .update({ value: JSON.stringify('settings/signatory-signature.png') }); + + const signed = await api.inject({ + method: 'POST', + url: '/api/applications/me/sign', + headers, + payload: { type: 'TYPED', typedName: 'Asha Rao', consent: true }, + }); + + expect(signed.statusCode).toBe(200); + expect(signed.result).toMatchObject({ status: 'APPROVED' }); + expect(signed.result.surveyToken).toEqual(expect.any(String)); + const row = await db('applications').where({ email }).first(); + expect(row.status).toBe('APPROVED'); + expect(row.final_pdf_key).toBeTruthy(); + expect(row.decided_by).toBeNull(); + expect( + await db('audit_log').where({ action: 'APPLICATION_AUTO_APPROVED' }).first(), + ).toBeTruthy(); + expect(await db('survey_tokens').where({ application_id: row.id }).first()).toBeTruthy(); + }); + it('returns a rejected application to draft from edit and keeps the reason', async () => { const api = await start(); await postEmail(api); diff --git a/backend/test/consent.test.js b/backend/test/consent.test.js new file mode 100644 index 0000000..08e2710 --- /dev/null +++ b/backend/test/consent.test.js @@ -0,0 +1,132 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { createServer } from '../src/app.js'; +import { db } from '../src/db/knex.js'; +import { clearOutbox, outbox } from '../src/services/mail.js'; +import { truncateTables } from './truncate.js'; + +const email = 'consent-doctor@example.com'; + +const answers = { + voluntary_withdraw: true, + mou_required: true, + eligibility_six_months: true, + confidential_research: true, + web_questionnaire: true, + no_intervention: true, + aggregated_reporting: true, + honorarium: true, + agree_participate: true, +}; + +/** + * @param {import('@hapi/hapi').ServerInjectResponse} response + */ +function userCookie(response) { + const raw = response.headers['set-cookie']; + const headers = Array.isArray(raw) ? raw : raw ? [raw] : []; + const match = headers.find((header) => header.startsWith('user_token=')); + return match ? match.split(';')[0] : null; +} + +describe('consent', () => { + /** @type {import('@hapi/hapi').Server | undefined} */ + let server; + + afterEach(async () => { + clearOutbox(); + await server?.stop(); + await truncateTables(); + }); + + async function start() { + server = await createServer(); + return server; + } + + async function login(api) { + await api.inject({ + method: 'POST', + url: '/api/auth/otp/request', + payload: { email }, + }); + + const verified = await api.inject({ + method: 'POST', + url: '/api/auth/otp/verify', + payload: { email, otp: outbox[0].otp }, + }); + + return userCookie(verified); + } + + it('submits consent and advances nextStep to agreement', async () => { + const api = await start(); + const cookie = await login(api); + + const before = await api.inject({ + method: 'GET', + url: '/api/me', + headers: { cookie }, + }); + expect(before.result.nextStep).toBe('consent'); + + const submitted = await api.inject({ + method: 'POST', + url: '/api/consent', + headers: { cookie }, + payload: { + participant_name: 'Dr Consent Test', + consent_date: '2026-10-16', + answers, + }, + }); + + expect(submitted.statusCode).toBe(200); + expect(submitted.result).toMatchObject({ + participant_name: 'Dr Consent Test', + consent_date: '2026-10-16', + }); + + const me = await api.inject({ + method: 'GET', + url: '/api/me', + headers: { cookie }, + }); + expect(me.result.steps.consent.done).toBe(true); + expect(me.result.nextStep).toBe('agreement'); + expect(me.result.user.name).toBe('Dr Consent Test'); + + const again = await api.inject({ + method: 'POST', + url: '/api/consent', + headers: { cookie }, + payload: { + participant_name: 'Dr Consent Test', + consent_date: '2026-10-16', + answers, + }, + }); + expect(again.statusCode).toBe(409); + + const row = await db('consents').where({ participant_name: 'Dr Consent Test' }).first(); + expect(row).toBeTruthy(); + }); + + it('rejects incomplete consent answers', async () => { + const api = await start(); + const cookie = await login(api); + + const response = await api.inject({ + method: 'POST', + url: '/api/consent', + headers: { cookie }, + payload: { + participant_name: 'Dr Consent Test', + consent_date: '2026-10-16', + answers: { ...answers, eligibility_six_months: false }, + }, + }); + + expect(response.statusCode).toBe(400); + }); +}); diff --git a/backend/test/mail.test.js b/backend/test/mail.test.js index eb18385..1d59773 100644 --- a/backend/test/mail.test.js +++ b/backend/test/mail.test.js @@ -163,9 +163,9 @@ describe('agreement email', () => { const first = await api.inject({ method: 'GET', url: `/api/survey/${token}` }); const second = await api.inject({ method: 'GET', url: `/api/survey/${token}` }); expect(first.statusCode).toBe(200); - expect(first.result.version).toBe(1); + expect(first.result.version).toBe(2); expect(second.statusCode).toBe(200); - expect(second.result.version).toBe(1); + expect(second.result.version).toBe(2); await db('survey_tokens').where({ application_id: id }).update({ used_at: new Date() }); const used = await api.inject({ method: 'GET', url: `/api/survey/${token}` }); diff --git a/backend/test/renderPdf.test.js b/backend/test/renderPdf.test.js index 08d036c..6f33bb4 100644 --- a/backend/test/renderPdf.test.js +++ b/backend/test/renderPdf.test.js @@ -1,5 +1,5 @@ import { EventEmitter } from 'node:events'; -import { access, writeFile } from 'node:fs/promises'; +import { access, readFile, writeFile } from 'node:fs/promises'; import { beforeEach, describe, expect, it, vi } from 'vitest'; const spawn = vi.hoisted(() => vi.fn()); @@ -38,12 +38,39 @@ describe('renderPdf', () => { expect(spawn).toHaveBeenCalledWith( 'weasyprint', expect.any(Array), - expect.objectContaining({ stdio: ['ignore', 'ignore', 'pipe'] }), + expect.objectContaining({ + stdio: ['ignore', 'ignore', 'pipe'], + cwd: expect.any(String), + }), ); const htmlPath = spawn.mock.calls[0][1][0]; await expect(access(htmlPath)).rejects.toThrow(); }); + it('writes data-URI images beside the HTML for WeasyPrint', async () => { + /** @type {string | undefined} */ + let writtenHtml; + + spawn.mockImplementation((_bin, args) => { + const proc = child(); + queueMicrotask(async () => { + writtenHtml = await readFile(args[0], 'utf8'); + await writeFile(args[1], Buffer.from('%PDF-1.4')); + proc.emit('close', 0); + }); + return proc; + }); + + const tiny = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='; + await renderPdf( + `sig`, + ); + + expect(writtenHtml).toContain('src="embed-0.png"'); + expect(writtenHtml).not.toContain('data:image/png;base64'); + }); + it('reports a missing WeasyPrint binary', async () => { spawn.mockImplementation(() => { const proc = child(); diff --git a/backend/test/storage.test.js b/backend/test/storage.test.js index 56189b7..6814372 100644 --- a/backend/test/storage.test.js +++ b/backend/test/storage.test.js @@ -14,7 +14,7 @@ describe('local storage', () => { await deleteObject(key); }); - it('stores an object and returns a five-minute signed URL', async () => { + it('stores an object and returns a one-hour signed URL', async () => { await putObject(key, Buffer.from('hello')); expect((await getObject(key)).toString()).toBe('hello'); @@ -24,8 +24,8 @@ describe('local storage', () => { const signature = url.searchParams.get('sig'); expect(url.pathname).toContain(encodeURIComponent(key)); - expect(Number(expires) - Date.now()).toBeGreaterThan(4 * 60 * 1000); - expect(Number(expires) - Date.now()).toBeLessThanOrEqual(5 * 60 * 1000); + expect(Number(expires) - Date.now()).toBeGreaterThan(55 * 60 * 1000); + expect(Number(expires) - Date.now()).toBeLessThanOrEqual(60 * 60 * 1000); expect(verifyLocalSignature(key, expires, signature)).toBe(true); expect(verifyLocalSignature(key, String(Date.now() - 1000), signature)).toBe(false); }); diff --git a/backend/test/survey.test.js b/backend/test/survey.test.js index ade7be1..1542346 100644 --- a/backend/test/survey.test.js +++ b/backend/test/survey.test.js @@ -258,7 +258,7 @@ describe('survey submission', () => { const { applicationId, token } = await issueToken(); const opened = await api.inject({ method: 'GET', url: `/api/survey/${token}` }); expect(opened.statusCode).toBe(200); - expect(opened.result.version).toBe(1); + expect(opened.result.version).toBe(2); expect(opened.result.answers.name).toBe('Asha Rao'); const saved = await api.inject({ @@ -270,15 +270,15 @@ describe('survey submission', () => { expect(saved.result.answers.city).toBe('Pune'); expect(saved.result.answers.ignored).toBeUndefined(); - await db('survey_definitions').where({ version: 1 }).update({ is_active: false }); + await db('survey_definitions').where({ version: 2 }).update({ is_active: false }); await db('survey_definitions').insert({ - version: 2, + version: 3, title: 'Replacement survey', definition: JSON.stringify({ sections: [] }), is_active: true, }); const pinned = await api.inject({ method: 'GET', url: `/api/survey/${token}` }); - expect(pinned.result.version).toBe(1); + expect(pinned.result.version).toBe(2); expect(pinned.result.answers.city).toBe('Pune'); const answers = completeAnswers(surveyDefinition); diff --git a/backend/test/surveyExport.test.js b/backend/test/surveyExport.test.js index 35847c1..0563ff0 100644 --- a/backend/test/surveyExport.test.js +++ b/backend/test/surveyExport.test.js @@ -83,8 +83,11 @@ describe('survey export', () => { await workbook.xlsx.load(response.rawPayload); const responses = workbook.getWorksheet('Responses'); const headers = responses.getRow(1).values.slice(1); - expect(headers).toContain('Prescribing % - Extended-Dose Rotacap - COPD'); - const column = headers.indexOf('Prescribing % - Extended-Dose Rotacap - COPD') + 1; + expect(headers).toContain( + 'Prescribing % - Extended-Dose Pack — Rotacap (pack of 60) - COPD', + ); + const column = + headers.indexOf('Prescribing % - Extended-Dose Pack — Rotacap (pack of 60) - COPD') + 1; expect(responses.getRow(2).getCell(column).value).toBe(25); const questions = workbook.getWorksheet('Questions'); const questionIds = questions.getColumn(1).values; diff --git a/backend/test/truncate.js b/backend/test/truncate.js index ffef1b8..26de4cc 100644 --- a/backend/test/truncate.js +++ b/backend/test/truncate.js @@ -8,7 +8,10 @@ const TABLES = [ 'survey_definitions', 'signatures', 'resume_tokens', + 'consents', + 'login_otps', 'applications', + 'users', 'admin_users', ]; diff --git a/backend/test/userAuth.test.js b/backend/test/userAuth.test.js new file mode 100644 index 0000000..ba67139 --- /dev/null +++ b/backend/test/userAuth.test.js @@ -0,0 +1,120 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { createServer } from '../src/app.js'; +import { db } from '../src/db/knex.js'; +import { clearOutbox, outbox } from '../src/services/mail.js'; +import { truncateTables } from './truncate.js'; + +const email = 'doctor@example.com'; + +/** + * @param {import('@hapi/hapi').ServerInjectResponse} response + * @param {string} name + */ +function cookieNamed(response, name) { + const raw = response.headers['set-cookie']; + const headers = Array.isArray(raw) ? raw : raw ? [raw] : []; + const match = headers.find((header) => header.startsWith(`${name}=`)); + return match ? match.split(';')[0] : null; +} + +describe('user OTP auth', () => { + /** @type {import('@hapi/hapi').Server | undefined} */ + let server; + + afterEach(async () => { + clearOutbox(); + await server?.stop(); + await truncateTables(); + }); + + async function start() { + server = await createServer(); + return server; + } + + it('requests an OTP, verifies it, returns /api/me, and logs out', async () => { + const api = await start(); + + const requested = await api.inject({ + method: 'POST', + url: '/api/auth/otp/request', + payload: { email }, + }); + + expect(requested.statusCode).toBe(200); + expect(requested.result).toMatchObject({ ok: true }); + + const user = await db('users').where({ email }).first(); + expect(user).toBeTruthy(); + + const application = await db('applications').where({ user_id: user.id }).first(); + expect(application).toMatchObject({ email, status: 'DRAFT' }); + + expect(outbox).toHaveLength(1); + expect(outbox[0]?.template).toBe('login_otp'); + expect(outbox[0]?.otp).toMatch(/^\d{6}$/); + + const verified = await api.inject({ + method: 'POST', + url: '/api/auth/otp/verify', + payload: { email, otp: outbox[0].otp }, + }); + + expect(verified.statusCode).toBe(200); + expect(verified.result).toMatchObject({ + user: { email }, + me: { nextStep: 'consent' }, + }); + + const session = cookieNamed(verified, 'user_token'); + expect(session).toBeTruthy(); + + const me = await api.inject({ + method: 'GET', + url: '/api/me', + headers: { cookie: session }, + }); + + expect(me.statusCode).toBe(200); + expect(me.result).toMatchObject({ + user: { email }, + steps: { + consent: { done: false }, + agreement: { done: false }, + survey: { done: false }, + }, + nextStep: 'consent', + }); + + const logout = await api.inject({ + method: 'POST', + url: '/api/auth/logout', + headers: { cookie: session }, + }); + + expect(logout.statusCode).toBe(200); + expect(cookieNamed(logout, 'user_token')).toMatch(/user_token=/); + expect(String(logout.headers['set-cookie'])).toMatch(/Max-Age=0|Expires=/i); + + const missing = await api.inject({ method: 'GET', url: '/api/me' }); + expect(missing.statusCode).toBe(401); + }); + + it('rejects an invalid OTP', async () => { + const api = await start(); + + await api.inject({ + method: 'POST', + url: '/api/auth/otp/request', + payload: { email }, + }); + + const verified = await api.inject({ + method: 'POST', + url: '/api/auth/otp/verify', + payload: { email, otp: '000000' }, + }); + + expect(verified.statusCode).toBe(401); + }); +}); diff --git a/files/docs/agreement.md b/files/docs/agreement.md index e51e596..8eb7734 100644 --- a/files/docs/agreement.md +++ b/files/docs/agreement.md @@ -1,10 +1,10 @@ CONSULTATION AGREEMENT -This Consultation Agreement is is executed on this ___ day of ________ 2026 at Pune, Maharashtra. +This Consultation Agreement is executed on this ___ day of ________ 2026 at Pune, Maharashtra. BETWEEN -CREST Private Limited, a company incorporated under the provisions of the Companies Act, 2013, having its registered office at Pune, India. +Chest Research And Training Private Limited, a company incorporated under the provisions of the Companies Act, 2013, having its registered office at Pune, India. (hereinafter referred to as "CREST", which expression shall, unless repugnant to the context or meaning thereof, include its successors and permitted assigns); @@ -20,7 +20,7 @@ Dr. ______________________________, aged ___ years, residing at ________________ CREST and the Physician are hereinafter individually referred to as a "Party" and collectively as the "Parties." -WHEREAS, CREST Private Limited ("CREST") has been engaged by Cipla Limited ("Sponsor") to conduct an independent physician opinion survey titled "Foracort Adherence Pack Study" for the purpose of collecting professional medical opinions from practicing physicians. +WHEREAS, Chest Research And Training Private Limited ("CREST") has been engaged by Cipla Limited ("Sponsor") to conduct an independent physician opinion survey titled "Physicians' Perceptions of the Impact of an Extended-Dose Pack versus Regular Pack on Adherence to Inhaled Maintenance Medication in Asthma and COPD: A Cross-Sectional Survey" for the purpose of collecting professional medical opinions from practicing physicians. AND WHEREAS, the said study is intended solely to obtain independent professional opinions through a structured online questionnaire for research, scientific, educational, and analytical purposes, and does not involve the promotion, endorsement, or prescription of any pharmaceutical product. diff --git a/files/docs/pis.md b/files/docs/pis.md new file mode 100644 index 0000000..74eea29 --- /dev/null +++ b/files/docs/pis.md @@ -0,0 +1,65 @@ +# ONLINE PARTICIPANT INFORMATION SHEET (PIS) + +**Document:** PIS and ICF V01 | **Date:** 14/10/2026 | CREST Pvt. Ltd. | Confidential + +## Survey Title + +Physicians’ Perceptions of the Impact of an Extended-Dose Pack versus Regular Pack on Adherence to Inhaled Maintenance Medication in Asthma and COPD: A Cross-Sectional Survey + +## Background + +Asthma and chronic obstructive pulmonary disease (COPD) are chronic respiratory diseases that often require long-term treatment with inhaled maintenance medications. The effectiveness of treatment depends not only on the use of appropriate medication and correct inhaler technique but also on consistent adherence to the prescribed treatment. + +For patient convenience, pharmaceutical companies in India also offer inhaled medications in extended-dose packs, such as dry powder inhaler (DPI) capsule packs containing a greater number of capsules and pressurized metered-dose inhalers (pMDIs) containing a greater number of inhaler puffs, while keeping the cost per unit the same. + +Routine clinical practice has not adequately studied the perceived impact of the Extended-Dose Pack on adherence to inhaled maintenance therapy. Therefore, we are conducting this survey to evaluate physicians’ perceptions regarding patient adherence to inhaled maintenance therapy, reasons for non-adherence, the potential benefits of the Extended-Dose Pack, and patient characteristics that may influence the choice of an Extended-Dose Pack compared with a Regular Pack. + +CREST would like to invite you to participate in this survey, which will be conducted among approximately 500 chest physicians and consultant physicians across India who have been prescribing both the Extended-Dose Pack and the Regular Pack for at least six months. You have been selected through purposive sampling from a predefined list of eligible physicians. + +## About CREST + +Chest Research and Training Pvt. Ltd. (CREST), Pune is an academic research and training organization founded by Dr. Sundeep Salvi. Dedicated to research, education and innovation in respiratory medicine. CREST focuses on generating high-quality evidence to improve respiratory healthcare in India, particularly in obstructive airways diseases. + +Equipped with state-of-the-art lung function testing facilities and a multidisciplinary team of clinicians, scientists, and allied professionals, CREST has extensive experience in epidemiological studies, clinical research and real-world generation. + +## What will you be expected to do? + +If you agree to participate, you will be asked to complete a simple, self-administered, web-based questionnaire. The questionnaire will collect the following information: + +- Your professional qualifications and clinical practice +- Your experience with prescribing the Extended-Dose Pack and Regular Pack +- Your perceptions of patient adherence with the two pack types +- Reasons for perceived non-adherence +- Potential benefits and suitable patient profile for the Extended-Dose Pack vs Regular Pack + +## Duration + +The questionnaire is expected to require approximately 10–15 minutes to complete. + +## Confidentiality + +Your responses will be confidential, and your identity will not be disclosed to anyone. Your participation is entirely voluntary, and you can withdraw from the survey anytime. However, we would really appreciate your participation and professional insights, as your experience may contribute to a better understanding of medication adherence in routine clinical practice. + +## Approval + +This survey is approved by the Central Independent Ethics Committee - CIEC, Pune for approval for all states in India. + +## Funding and Compensation + +This survey is supported by Cipla Ltd. You will receive a professional honorarium of ₹15,000 as compensation for your time and participation, upon receipt of completed questionnaire and provision of your professional opinions and experience regarding prescribing practices. + +As part of your participation, you will be required to sign a Memorandum of Understanding (MOU) with CREST outlining the terms and conditions of your participation in the survey. + +## Contact Details + +Feel free to contact for any query/clarification. + +**Principal Investigator** +Name: Dr. Sundeep Salvi +Mob: +91 9921211000 +Email: sundeepsalvi@gmail.com + +**Co-investigator** +Name: Ms. Sapna Madas +Mob: +91 9850085305 +Email: sapnamadas@crestindia.co.in diff --git a/frontend/package.json b/frontend/package.json index f72df41..17c3169 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,6 +8,7 @@ }, "scripts": { "dev": "vite", + "dev:remote": "API_PROXY_TARGET=https://pppaimt-crest.xltr.in vite", "build": "vite build", "preview": "vite preview", "test": "vitest run" diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index d614a79..61dacc5 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -15,9 +15,13 @@ import ReviewPage from './pages/ReviewPage.jsx'; import SignPage from './pages/SignPage.jsx'; import SubmittedPage from './pages/SubmittedPage.jsx'; import SurveyPage from './pages/SurveyPage.jsx'; +import ConsentPage from './pages/ConsentPage.jsx'; +import UserDashboardPage from './pages/UserDashboardPage.jsx'; +import UserLoginPage from './pages/UserLoginPage.jsx'; import SiteFooter from './SiteFooter.jsx'; import SiteHeader from './SiteHeader.jsx'; import TokenReferrer from './TokenReferrer.jsx'; +import UserLayout from './user/UserLayout.jsx'; function PublicShell({ children }) { return ( @@ -32,14 +36,16 @@ function PublicShell({ children }) { export default function App() { return ( - - - - } - /> + } /> + } /> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> - } /> + } /> } /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> { - it('renders the placeholder', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('redirects to the physician login page', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ + ok: false, + status: 401, + json: async () => ({ error: { message: 'Authentication required' } }), + })), + ); + const queryClient = new QueryClient(); render( @@ -16,9 +29,60 @@ describe('home route', () => { , ); - expect(screen.getByRole('heading', { name: 'Agreement' })).toBeInTheDocument(); + expect(await screen.findByRole('heading', { name: 'Sign in' })).toBeInTheDocument(); + expect(screen.getByLabelText('Email')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Send code' })).toBeInTheDocument(); expect(screen.getByRole('img', { name: 'CREST' })).toBeInTheDocument(); - expect(screen.getByText('Adherence Pack Study')).toBeInTheDocument(); - expect(screen.getByText('Chest Research and Training Pvt. Ltd.')).toBeInTheDocument(); + }); + + it('loads the dashboard after a session is found', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async (path) => { + if (path === '/api/me') { + return { + ok: true, + status: 200, + json: async () => ({ + user: { id: 1, email: 'doctor@example.com', name: null }, + application: { id: 1, status: 'DRAFT', email: 'doctor@example.com' }, + steps: { + consent: { done: false }, + agreement: { done: false }, + survey: { done: false, status: null }, + }, + nextStep: 'consent', + }), + }; + } + + return { + ok: false, + status: 404, + json: async () => ({}), + }; + }), + ); + + const queryClient = new QueryClient(); + + render( + + + + + , + ); + + await waitFor(() => { + expect(screen.getByRole('heading', { name: 'Dashboard' })).toBeInTheDocument(); + }); + expect(screen.getByText('Signed in as doctor@example.com')).toBeInTheDocument(); + expect(screen.getByText('1. Consent (PIS / ICF)')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /Continue to consent/i })).toHaveAttribute( + 'href', + '/consent', + ); }); }); + diff --git a/frontend/src/api.js b/frontend/src/api.js index be5098c..4fa71db 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -27,8 +27,14 @@ export async function api(path, options = {}) { const response = await fetch(path, { method: options.method ?? 'GET', headers, + credentials: 'include', body: options.body ? JSON.stringify(options.body) : undefined, }); + + if (response.status === 204) { + return null; + } + const data = await response.json().catch(() => ({})); if (!response.ok) { diff --git a/frontend/src/consentQuestions.js b/frontend/src/consentQuestions.js new file mode 100644 index 0000000..7cb38a1 --- /dev/null +++ b/frontend/src/consentQuestions.js @@ -0,0 +1,57 @@ +export const CONSENT_QUESTIONS = [ + { + id: 'voluntary_withdraw', + label: + 'I understand that my participation in the survey is voluntary, and I am free to withdraw my consent at any time, without giving any reason, without my legal rights being affected.', + }, + { + id: 'mou_required', + label: + 'I understand that I need to sign a Memorandum of Understanding (MOU) with CREST to participate in this survey.', + }, + { + id: 'eligibility_six_months', + label: + 'I confirm that I have been prescribing both the Extended-Dose Pack and Regular Pack for at least 6 months and have experience with their use in routine clinical practice.', + }, + { + id: 'confidential_research', + label: + 'I understand that the information I provide will be treated confidentially and used for research purposes.', + }, + { + id: 'web_questionnaire', + label: + 'I understand that my participation involves completing a web-based survey questionnaire.', + }, + { + id: 'no_intervention', + label: + 'I understand that the survey does not involve any intervention or patient-level data collection.', + }, + { + id: 'aggregated_reporting', + label: + 'I understand that my responses may be analyzed and reported in an aggregated form for research/scientific purposes.', + }, + { + id: 'honorarium', + label: + 'I understand that I will receive a professional honorarium of ₹15,000 upon completion of the survey questionnaire, as described above.', + }, + { + id: 'agree_participate', + label: 'I agree and consent to participate in the survey.', + }, +]; + +export function emptyConsentAnswers() { + return Object.fromEntries(CONSENT_QUESTIONS.map((question) => [question.id, false])); +} + +export function todayIsoDate() { + const now = new Date(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const day = String(now.getDate()).padStart(2, '0'); + return `${now.getFullYear()}-${month}-${day}`; +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 5374dc2..d177817 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -91,6 +91,12 @@ body { color: #44403c; } +.site label:has(input[type='radio']), +.site label:has(input[type='checkbox']), +.survey-choice { + font-size: 0.9rem; +} + .site input, .site textarea, .site button, @@ -187,6 +193,9 @@ a:focus-visible { .site-brand span { font-size: 1.4rem; } + .user-brand span { + font-size: 1.4rem; + } } .site main { @@ -275,18 +284,23 @@ a:focus-visible { color: var(--brand); } -.site button { +.site button, +.site a.button { + display: inline-block; padding: 0.7rem 1.15rem; border: 0; border-radius: 0.55rem; background: linear-gradient(135deg, var(--brand) 0%, #1578c8 100%); color: #f0fdfa; font-weight: 600; + text-decoration: none; cursor: pointer; } -.site button:hover:not(:disabled) { +.site button:hover:not(:disabled), +.site a.button:hover { background: linear-gradient(135deg, #0d6a63 0%, #1d4ed8 100%); + color: #f0fdfa; } .site button:disabled { @@ -433,7 +447,80 @@ a:focus-visible { } .error { - color: #b91c1c; + color: #b91c1c !important; +} + +.field-error { + margin: 0.2rem 0 0.75rem; + color: #b91c1c !important; + font-size: 0.78rem; + line-height: 1.35; +} + +.field-tip { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.05rem; + height: 1.05rem; + margin-left: 0.35rem; + border: 1px solid var(--border); + border-radius: 999px; + background: #f8fafc; + color: var(--text-muted); + font-size: 0.7rem; + font-weight: 700; + line-height: 1; + cursor: help; + vertical-align: middle; +} + +.field-tip:focus-visible { + outline: 2px solid var(--brand); + outline-offset: 2px; +} + +.field-hint { + margin: 0.2rem 0 0.65rem; + color: var(--text-muted); + font-size: 0.78rem; + line-height: 1.35; +} + +.cheque-preview { + display: flex; + align-items: center; + gap: 0.75rem; + margin: 0.35rem 0 0.75rem; + font-size: 0.875rem; +} + +.cheque-image { + display: block; + height: 4rem; + margin: 0; + padding: 0.35rem; + border: 1px solid var(--border); + border-radius: 0.5rem; + background: #f8fafc; + object-fit: contain; +} + +.cheque-remove { + padding: 0.3rem 0.65rem; + font-size: 0.8rem; + line-height: 1.2; +} + +.cheque-status { + margin: 0 0 0.5rem; + font-size: 0.875rem; + color: #0f766e; +} + +.agreement-page .admin-file:disabled { + opacity: 0.55; + cursor: not-allowed; } .site main.wide { @@ -791,3 +878,346 @@ a:focus-visible { clip: auto; } } + +.user-shell { + min-height: 100vh; + display: flex; + flex-direction: column; + background: var(--page); +} + +.user-shell-loading { + margin: 2rem; + color: var(--text-muted); +} + +.user-topbar { + position: relative; + z-index: 40; + background: rgb(255 255 255 / 0.92); + -webkit-backdrop-filter: blur(16px); + backdrop-filter: blur(16px); + border-bottom: 3px solid var(--brand); +} + +.user-topbar-inner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + width: 100%; + box-sizing: border-box; + margin: 0 auto; + padding: 0.75rem 1.5rem; +} + +.user-brand { + display: flex; + align-items: center; + gap: 0.85rem; + color: var(--brand-dark); + font-weight: 600; + text-decoration: none; +} + +.user-brand img { + height: 56px; + width: auto; + padding-right: 0.5rem; + border-right: 1px dotted var(--brand); +} + +.user-menu { + position: relative; +} + +.user-shell .user-menu-trigger { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.35rem; + min-width: 2.75rem; + padding: 0.45rem 0.65rem; + border: 1px solid rgb(255 255 255 / 0.35); + border-radius: 0.55rem; + font: inherit; + font-size: 0.875rem; + font-weight: 500; + line-height: 1; + cursor: pointer; +} + +.user-menu-icon { + display: block; + flex-shrink: 0; +} + +.user-menu-panel { + position: absolute; + right: 0; + top: calc(100% + 0.35rem); + z-index: 50; + min-width: 14rem; + padding: 0.5rem; + border: 1px solid var(--border); + border-radius: 0.6rem; + background: #fff; + box-shadow: var(--shadow); +} + +.user-menu-email { + margin: 0 0 0.15rem !important; + padding: 0.35rem 0.5rem; + font-size: 0.8rem; + color: var(--text-muted); + word-break: break-all; +} + +.user-shell .user-menu-panel button { + display: block; + width: 100%; + padding: 0.55rem 0.65rem; + border: 0; + border-radius: 0.45rem; + background: transparent; + color: var(--brand-dark); + font: inherit; + font-size: 0.875rem; + font-weight: 500; + text-align: left; + cursor: pointer; +} + +.user-shell .user-menu-panel button:hover:not(:disabled) { + background: #f3f4f6; + color: var(--brand-dark); +} + +.user-main { + flex: 1; + width: calc(100% - 3rem); + /* Keep the same narrow card as .site main for agreement / survey / etc. */ + max-width: 40rem; + margin: 1.5rem auto 2rem; + padding: 1.5rem; + box-sizing: border-box; + background: rgb(255 255 255 / 0.96); + border: 1px solid rgb(255 255 255 / 0.8); + border-radius: var(--radius); + box-shadow: var(--shadow); +} + +.site main.user-main.wide { + max-width: none; + width: calc(100% - 3rem); +} + +.user-dashboard h1 { + margin-top: 0; +} + +.user-step-list { + display: grid; + gap: 0.75rem; + margin: 1.25rem 0; +} + +.user-step { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 0.5rem; + padding: 0.85rem 1rem; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface-muted); +} + +.user-step.done { + border-color: #99f6e4; + background: #f0fdfa; +} + +.user-step span { + color: var(--text-muted); + font-size: 0.9rem; +} + +.user-next-hint { + margin-bottom: 0; + color: var(--text-muted); + font-size: 0.92rem; +} + +.user-detail-card { + margin: 1.25rem 0; + padding: 1rem 1.1rem; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: #fff; +} + +.user-detail-card h2 { + margin: 0 0 0.85rem; + font-size: 1.05rem; + color: var(--brand-dark); +} + +.user-detail-empty { + margin: 0; + color: var(--text-muted); + font-size: 0.92rem; +} + +.user-detail-grid { + display: grid; + gap: 0.65rem 1.25rem; + margin: 0; +} + +@media (min-width: 40rem) { + .user-detail-grid { + grid-template-columns: 1fr 1fr; + } +} + +.user-detail-grid dt { + margin: 0; + font-size: 0.78rem; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.02em; +} + +.user-detail-grid dd { + margin: 0.15rem 0 0; + font-size: 0.95rem; + color: var(--text); + word-break: break-word; +} + +.user-detail-links { + display: flex; + flex-wrap: wrap; + gap: 0.75rem 1.25rem; + margin-top: 1.5rem !important; +} + +.user-agreement-heading { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.65rem 0.85rem; + margin-bottom: 0.85rem; +} + +.user-agreement-heading h2 { + margin: 0; +} + +.user-status-badge { + display: inline-flex; + align-items: center; + padding: 0.2rem 0.55rem; + border-radius: 999px; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.03em; + text-transform: uppercase; +} + +.user-status-approved { + background: #dcfce7; + color: #14532d; +} + +.user-status-submitted { + background: #fef3c7; + color: #92400e; +} + +.user-status-rejected { + background: #fee2e2; + color: #991b1b; +} + +.user-status-draft { + background: #e7e5e4; + color: #44403c; +} + +.user-agreement-layout { + display: grid; + gap: 1rem; + margin: 1.25rem 0; +} + +@media (min-width: 64rem) { + .user-agreement-layout { + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + align-items: start; + } +} + +.user-agreement-layout > .user-detail-card { + margin: 0; +} + +.user-agreement-preview .preview-frame { + margin-bottom: 0; + min-height: 70vh; +} + +.user-cheque-block { + margin-top: 1rem; +} + +.user-cheque-block h3 { + margin: 0 0 0.5rem; + font-size: 0.92rem; + color: var(--brand-dark); +} + +.user-cheque-image { + display: block; + max-width: 100%; + max-height: 10rem; + padding: 0.35rem; + border: 1px solid var(--border); + border-radius: 0.5rem; + background: #f8fafc; + object-fit: contain; +} + +.consent-page h2 { + margin-top: 1.5rem; +} + +.consent-pis { + max-height: max(16rem, calc(100svh - 28rem)); + white-space: pre-wrap; +} + +.consent-checks { + display: grid; + gap: 0.85rem; + margin: 1rem 0 1.25rem; +} + +.consent-check { + margin-bottom: 0; + align-items: flex-start; +} + +.consent-fields { + display: grid; + gap: 1rem; + margin-bottom: 1rem; +} + +@media (min-width: 40.0625rem) { + .consent-fields { + grid-template-columns: 1fr 1fr; + } +} diff --git a/frontend/src/pages/AgreementPage.jsx b/frontend/src/pages/AgreementPage.jsx index e3ddd89..ca36591 100644 --- a/frontend/src/pages/AgreementPage.jsx +++ b/frontend/src/pages/AgreementPage.jsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react'; import { useForm } from 'react-hook-form'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useOutletContext } from 'react-router-dom'; import agreementText from '../../../files/docs/agreement.md?raw'; import { api, getResumeToken, saveResumeToken } from '../api.js'; import FlowSteps from '../FlowSteps.jsx'; @@ -55,13 +55,145 @@ export function upperValue(value) { return value.toUpperCase(); } +/** + * @param {string} message + * @returns {string | null} + */ +export function fieldKeyForMessage(message) { + const text = message.toLowerCase(); + + if (text.includes('registration number')) { + return 'registration_no'; + } + + if (text.includes('email')) { + return 'email'; + } + + if (text.includes('agreement date') || text.includes('choose the agreement date')) { + return 'agreement_date'; + } + + if (text.includes('full name')) { + return 'full_name'; + } + + if (text.includes('age')) { + return 'age'; + } + + if (text.includes('address')) { + return 'address'; + } + + if (text.includes('medical council')) { + return 'medical_council'; + } + + if (text.includes('gstin')) { + return 'gstin'; + } + + if (text.includes('pan')) { + return 'pan'; + } + + if (text.includes('bank name')) { + return 'bank_name'; + } + + if (text.includes('account holder')) { + return 'account_holder'; + } + + if (text.includes('account number')) { + return 'account_number'; + } + + if (text.includes('ifsc')) { + return 'ifsc'; + } + + if ( + text.includes('cheque') || + text.includes('png or jpeg') || + text.includes('500kb') || + text.includes('5mb') + ) { + return 'cancelled_cheque'; + } + + if (text.includes('accept the agreement')) { + return 'accepted'; + } + + return null; +} + +const BANK_TIPS = { + bank_name: 'Enter the full bank name as printed on your account or cheque', + account_holder: 'Name as per bank account only', + account_number: 'Account number without spaces or hyphens', + ifsc: '11-character IFSC code from your cheque book or passbook', + cancelled_cheque: 'Optional. Upload a clear PNG or JPEG of a cancelled cheque (max 5MB)', +}; + +const CHEQUE_MAX_BYTES = 5 * 1024 * 1024; + +/** + * @param {{ message: string }} props + */ +function FieldError({ message }) { + if (!message) { + return null; + } + + return ( +

+ * {message} +

+ ); +} + +/** + * @param {{ text: string }} props + */ +function FieldTip({ text }) { + return ( + + ? + + ); +} + +/** + * @param {File} file + * @returns {Promise} + */ +function readFileAsDataUrl(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result)); + reader.onerror = () => reject(new Error('Could not read the selected file')); + reader.readAsDataURL(file); + }); +} + export default function AgreementPage() { const navigate = useNavigate(); + const me = useOutletContext(); + const sessionEmail = me?.user?.email || ''; const [application, setApplication] = useState(null); const [loadError, setLoadError] = useState(''); const [saveError, setSaveError] = useState(''); + /** @type {[Record, Function]} */ + const [fieldErrors, setFieldErrors] = useState({}); const [status, setStatus] = useState(''); const [ready, setReady] = useState(false); + const [chequeUrl, setChequeUrl] = useState(''); + const [chequeBusy, setChequeBusy] = useState(false); + const [chequeStatus, setChequeStatus] = useState(''); + const chequeInputRef = useRef(/** @type {HTMLInputElement | null} */ (null)); const { register, reset, @@ -76,9 +208,40 @@ export default function AgreementPage() { const locked = readOnly || needsEdit; const lockedRef = useRef(locked); lockedRef.current = locked; + const hasSession = Boolean(me?.user?.id); + + /** + * @param {string} field + */ + function clearFieldError(field) { + setFieldErrors((current) => { + if (!current[field]) { + return current; + } + + const next = { ...current }; + delete next[field]; + return next; + }); + } + + /** + * @param {string} message + */ + function applyErrorMessage(message) { + const field = fieldKeyForMessage(message); + + if (field) { + setFieldErrors((current) => ({ ...current, [field]: message })); + setSaveError(''); + return; + } + + setSaveError(message); + } useEffect(() => { - if (!getResumeToken()) { + if (!hasSession && !getResumeToken()) { reset({ ...EMPTY, agreement_date: today(), @@ -96,9 +259,11 @@ export default function AgreementPage() { } setApplication(loaded); + setChequeUrl(loaded.cancelledChequeUrl || ''); reset({ ...EMPTY, ...loaded, + email: loaded.email || sessionEmail, agreement_date: loaded.agreement_date || today(), age: loaded.age ?? '', gstin: loaded.gstin ?? '', @@ -115,7 +280,7 @@ export default function AgreementPage() { return () => { cancelled = true; }; - }, [reset]); + }, [hasSession, reset, sessionEmail]); useEffect(() => { if (!ready) { @@ -123,7 +288,7 @@ export default function AgreementPage() { } const timer = setInterval(() => { - if (lockedRef.current || !getResumeToken()) { + if (lockedRef.current || (!hasSession && !getResumeToken())) { return; } @@ -134,15 +299,21 @@ export default function AgreementPage() { } api('/api/applications/me', { method: 'PUT', body: payload }) - .then(() => setStatus('Draft saved')) - .catch((error) => setSaveError(error.message)); + .then(() => { + setStatus('Draft saved'); + setSaveError(''); + }) + .catch((error) => { + setStatus(''); + applyErrorMessage(error.message); + }); }, 10000); return () => clearInterval(timer); - }, [dirtyFields, getValues, ready]); + }, [dirtyFields, getValues, hasSession, ready]); async function ensureDraft() { - if (getResumeToken()) { + if (hasSession || getResumeToken()) { return; } @@ -166,6 +337,7 @@ export default function AgreementPage() { async function continueToReview() { setSaveError(''); + setFieldErrors({}); const values = getValues(); const parsed = applicationSchema.safeParse({ ...values, @@ -174,7 +346,22 @@ export default function AgreementPage() { }); if (!parsed.success) { - setSaveError(parsed.error.issues[0].message); + /** @type {Record} */ + const next = {}; + + for (const issue of parsed.error.issues) { + const key = String(issue.path[0] || ''); + if (key && !next[key]) { + next[key] = issue.message; + } + } + + if (Object.keys(next).length > 0) { + setFieldErrors(next); + } else { + setSaveError(parsed.error.issues[0]?.message || 'Please check the form'); + } + return; } @@ -186,7 +373,7 @@ export default function AgreementPage() { await api('/api/applications/me?finalize=true', { method: 'PUT', body: fields }); navigate('/review'); } catch (error) { - setSaveError(error.message); + applyErrorMessage(error.message); } } @@ -196,25 +383,97 @@ export default function AgreementPage() { try { const updated = await api('/api/applications/me/edit', { method: 'POST' }); setApplication(updated); + setChequeUrl(updated.cancelledChequeUrl || ''); setStatus(''); } catch (error) { setSaveError(error.message); } } + /** + * @param {import('react').ChangeEvent} event + */ + async function onChequeSelected(event) { + const file = event.target.files?.[0]; + event.target.value = ''; + + if (!file || locked) { + return; + } + + if (!['image/png', 'image/jpeg'].includes(file.type)) { + applyErrorMessage('Upload a PNG or JPEG image'); + return; + } + + if (file.size > CHEQUE_MAX_BYTES) { + applyErrorMessage('The cancelled cheque image must be 5MB or smaller'); + return; + } + + setChequeBusy(true); + clearFieldError('cancelled_cheque'); + setSaveError(''); + setChequeStatus(''); + + try { + if (!hasSession && !getResumeToken()) { + await ensureDraft(); + } + + const dataUrl = await readFileAsDataUrl(file); + const saved = await api('/api/applications/me', { + method: 'PUT', + body: { cancelled_cheque: dataUrl }, + }); + setApplication(saved); + setChequeUrl(saved.cancelledChequeUrl || ''); + setChequeStatus('Cancelled cheque uploaded'); + } catch (error) { + applyErrorMessage(error.message); + } finally { + setChequeBusy(false); + } + } + + async function removeCheque() { + if (locked) { + return; + } + + setChequeBusy(true); + clearFieldError('cancelled_cheque'); + setSaveError(''); + setChequeStatus(''); + + try { + const saved = await api('/api/applications/me', { + method: 'PUT', + body: { cancelled_cheque: null }, + }); + setApplication(saved); + setChequeUrl(''); + setChequeStatus('Cancelled cheque removed'); + } catch (error) { + applyErrorMessage(error.message); + } finally { + setChequeBusy(false); + } + } + if (loadError) { return ( -
+

Agreement

{loadError}

-
+ ); } return ( -
+

Agreement

Read the terms, then enter your details to continue.

@@ -252,94 +511,229 @@ export default function AgreementPage() { {accepted ? ( <> -
- Agreement date - - -
-
- Physician details - - - - - - - -