#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.
This commit is contained in:
Amey Bhagwat 2026-10-19 09:56:57 +05:30
parent 5246560f1f
commit bb3f8b37a0
68 changed files with 4293 additions and 470 deletions

View File

@ -34,6 +34,17 @@ npm run dev
The Vite dev server proxies `/api` to port 4000. 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 ## Database
Create the application database, then: Create the application database, then:

View File

@ -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');
}

View File

@ -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');
}

View File

@ -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');
});
}

View File

@ -74,6 +74,7 @@ export async function createServer() {
'req.headers.authorization', 'req.headers.authorization',
'req.headers.cookie', 'req.headers.cookie',
'req.payload.password', 'req.payload.password',
'req.payload.otp',
'req.payload.pan', 'req.payload.pan',
'req.payload.account_number', 'req.payload.account_number',
'req.params.token', 'req.params.token',
@ -127,6 +128,19 @@ export async function createServer() {
}, },
}); });
server.auth.default({ strategy: 'admin', mode: 'try' }); 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); await registerRoutes(server);

View File

@ -1,6 +1,6 @@
import { db } from './knex.js'; import { db } from './knex.js';
import { seedSurveyDefinition } from '../survey/seed.js'; import { SURVEY_DEFINITION_VERSION, seedSurveyDefinition } from '../survey/seed.js';
const id = await seedSurveyDefinition(); 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(); await db.destroy();

View File

@ -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;
}

View File

@ -2,13 +2,20 @@ import { readFileSync } from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
const AGREEMENT_PATH = path.resolve( const ROOT = path.dirname(fileURLToPath(import.meta.url));
path.dirname(fileURLToPath(import.meta.url)), const AGREEMENT_PATH = path.resolve(ROOT, '../../../files/docs/agreement.md');
'../../../files/docs/agreement.md', const LOGO_PATH = path.resolve(ROOT, '../../../frontend/src/images/CREST_logo.jpg');
);
const sourceText = readFileSync(AGREEMENT_PATH, 'utf8'); 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 = [ const MONTHS = [
'January', 'January',
'February', 'February',
@ -80,6 +87,18 @@ function physicianSignatureHtml(signature) {
return '___________________'; return '___________________';
} }
/**
* @param {string} signatureMarkup
* @param {{ withLogo?: boolean }} [options]
*/
function signatureRow(signatureMarkup, options = {}) {
const logo =
options.withLogo && crestLogoDataUrl
? `<img class="crest-logo" alt="CREST" src="${crestLogoDataUrl}" />`
: '';
return `<span class="signature-row"><span class="signature-field">Signature: ${signatureMarkup}</span>${logo}</span>`;
}
/** /**
* @param {{ name: string, designation: string, signatureDataUrl?: string } | null} signatory * @param {{ name: string, designation: string, signatureDataUrl?: string } | null} signatory
* @param {string} dateLabel * @param {string} dateLabel
@ -91,12 +110,12 @@ function crestBlock(signatory, dateLabel) {
const image = signatory.signatureDataUrl const image = signatory.signatureDataUrl
? `<img class="signature" alt="CREST signature" src="${signatory.signatureDataUrl}" />` ? `<img class="signature" alt="CREST signature" src="${signatory.signatureDataUrl}" />`
: ''; : '___________________';
return [ return [
`Name: ${filled(signatory.name)}`, `Name: ${filled(signatory.name)}`,
`Designation: ${filled(signatory.designation)}`, `Designation: ${filled(signatory.designation)}`,
`Signature: ${image}`, signatureRow(image, { withLogo: true }),
`Date: ${filled(dateLabel)}`, `Date: ${filled(dateLabel)}`,
].join('\n\n'); ].join('\n\n');
} }
@ -287,7 +306,10 @@ export function agreementHtml(options) {
.filled { font-weight: 700; text-decoration: underline; text-underline-offset: 0.15em; } .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; } .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; } .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; }
</style> </style>
</head> </head>
<body> <body>

View File

@ -37,6 +37,44 @@ function binary() {
return env.WEASYPRINT_BIN || 'weasyprint'; 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<string>}
*/
async function materializeDataUriImages(html, directory) {
/** @type {Promise<void>[]} */
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} htmlPath
* @param {string} pdfPath * @param {string} pdfPath
@ -46,6 +84,7 @@ function runWeasyPrint(htmlPath, pdfPath) {
let settled = false; let settled = false;
const child = spawn(binary(), [htmlPath, pdfPath], { const child = spawn(binary(), [htmlPath, pdfPath], {
stdio: ['ignore', 'ignore', 'pipe'], stdio: ['ignore', 'ignore', 'pipe'],
cwd: path.dirname(htmlPath),
}); });
let stderr = ''; let stderr = '';
@ -96,7 +135,8 @@ export async function renderPdf(html) {
try { try {
const htmlPath = path.join(directory, 'agreement.html'); const htmlPath = path.join(directory, 'agreement.html');
const pdfPath = path.join(directory, 'agreement.pdf'); 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); await runWeasyPrint(htmlPath, pdfPath);
return await readFile(pdfPath); return await readFile(pdfPath);
} finally { } finally {

View File

@ -57,13 +57,21 @@ export const applicationRoutes = {
server.route({ server.route({
method: 'PUT', method: 'PUT',
path: '/api/applications/me', 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) { async handler(request) {
const application = await applicationFromRequest(request); const application = await applicationFromRequest(request);
const payload = const payload =
request.payload && typeof request.payload === 'object' ? request.payload : {}; request.payload && typeof request.payload === 'object' ? request.payload : {};
const finalize = request.query.finalize === 'true' || request.query.finalize === true; 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);
}, },
}); });

View File

@ -0,0 +1,37 @@
import { requireUser } from '../middleware/requireUser.js';
import { getConsentForUser, submitConsent } from '../services/consent.js';
/** @type {import('@hapi/hapi').Plugin<void>} */
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);
},
});
},
};

View File

@ -2,9 +2,11 @@ import { env } from '../config/env.js';
import { adminRoutes } from './admin.js'; import { adminRoutes } from './admin.js';
import { adminAuthRoutes } from './adminAuth.js'; import { adminAuthRoutes } from './adminAuth.js';
import { applicationRoutes } from './applications.js'; import { applicationRoutes } from './applications.js';
import { consentRoutes } from './consent.js';
import { devFileRoutes } from './devFiles.js'; import { devFileRoutes } from './devFiles.js';
import { healthRoutes } from './health.js'; import { healthRoutes } from './health.js';
import { surveyRoutes } from './survey.js'; import { surveyRoutes } from './survey.js';
import { userAuthRoutes } from './userAuth.js';
import { webhookRoutes } from './webhooks.js'; import { webhookRoutes } from './webhooks.js';
/** /**
@ -13,6 +15,8 @@ import { webhookRoutes } from './webhooks.js';
export async function registerRoutes(server) { export async function registerRoutes(server) {
await server.register(healthRoutes); await server.register(healthRoutes);
await server.register(adminAuthRoutes); await server.register(adminAuthRoutes);
await server.register(userAuthRoutes);
await server.register(consentRoutes);
await server.register(adminRoutes); await server.register(adminRoutes);
await server.register(applicationRoutes); await server.register(applicationRoutes);
await server.register(surveyRoutes); await server.register(surveyRoutes);

View File

@ -1,6 +1,12 @@
import Boom from '@hapi/boom'; import Boom from '@hapi/boom';
import { z } from 'zod'; 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({ const answersSchema = z.object({
answers: z.record(z.string().max(80), z.unknown()), answers: z.record(z.string().max(80), z.unknown()),
@ -36,6 +42,19 @@ function answersFrom(payload) {
export const surveyRoutes = { export const surveyRoutes = {
name: 'survey-routes', name: 'survey-routes',
register(server) { 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({ server.route({
method: 'GET', method: 'GET',
path: '/api/survey/{token}', path: '/api/survey/{token}',

View File

@ -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<void>} */
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));
},
});
},
};

View File

@ -59,6 +59,8 @@ export const draftApplicationSchema = z
account_number: optionalText(text(z.string().regex(accountPattern))), account_number: optionalText(text(z.string().regex(accountPattern))),
ifsc: optionalText(text(z.string().regex(ifscPattern))), ifsc: optionalText(text(z.string().regex(ifscPattern))),
accepted: z.boolean().optional(), 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(); .strict();

View File

@ -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,
}),
});

View File

@ -1,12 +1,10 @@
import { createHash } from 'node:crypto';
import Boom from '@hapi/boom'; import Boom from '@hapi/boom';
import argon2 from 'argon2'; import argon2 from 'argon2';
import { z } from 'zod'; import { z } from 'zod';
import { db } from '../db/knex.js'; import { db } from '../db/knex.js';
import { auditFromRequest } from '../middleware/audit.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 { recordAudit } from './audit.js';
import { finalizeSubmittedApplication } from './applicationApproval.js';
import { presentApplication } from './applications.js'; import { presentApplication } from './applications.js';
import { maskSecret, panLookup } from './fieldCrypto.js'; import { maskSecret, panLookup } from './fieldCrypto.js';
import { readPngDataUrl } from './png.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_KEY = 'settings/signatory-signature.png';
const SIGNATURE_LIMIT = 500 * 1024; const SIGNATURE_LIMIT = 500 * 1024;
const STATUSES = ['DRAFT', 'SUBMITTED', 'APPROVED', 'REJECTED']; const STATUSES = ['DRAFT', 'SUBMITTED', 'APPROVED', 'REJECTED'];
const MISSING_SIGNATURE = 'Upload the CREST signatory signature in Settings before approving.';
const blankToUndefined = (value) => (value === '' ? undefined : value); const blankToUndefined = (value) => (value === '' ? undefined : value);
@ -119,13 +116,6 @@ function startOfWeek() {
return date; 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 * @param {string} image
*/ */
@ -323,31 +313,6 @@ export async function getApplication(id) {
}; };
} }
/**
* @param {Record<string, unknown>} 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 {import('@hapi/hapi').Request} request
* @param {number} id * @param {number} id
@ -355,94 +320,11 @@ async function physicianSignature(application) {
export async function approveApplication(request, id) { export async function approveApplication(request, id) {
const application = await applicationById(id); const application = await applicationById(id);
if (application.status !== 'SUBMITTED') { await finalizeSubmittedApplication(request, application, {
throw Boom.conflict('Only a submitted application can be approved'); decidedBy: request.pre.admin.id,
} actorType: 'ADMIN',
actorId: request.pre.admin.id,
const signatory = await getSignatory(); action: 'APPLICATION_APPROVED',
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,
}); });
return getApplication(id); return getApplication(id);

View File

@ -7,6 +7,7 @@ import { agreementHtml } from '../pdf/agreementHtml.js';
import { renderPdf } from '../pdf/render.js'; import { renderPdf } from '../pdf/render.js';
import { readPngDataUrl } from './png.js'; import { readPngDataUrl } from './png.js';
import { recordAudit } from './audit.js'; import { recordAudit } from './audit.js';
import { finalizeSubmittedApplication } from './applicationApproval.js';
import { import {
applicationFromRequest, applicationFromRequest,
assertCompleteApplication, assertCompleteApplication,
@ -36,9 +37,25 @@ const signSchema = z.discriminatedUnion('type', [
*/ */
export async function presentWithPdf(application) { export async function presentWithPdf(application) {
const body = presentApplication(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) { if (signedKey) {
body.signedPdfUrl = await getPresignedUrl(String(application.signed_pdf_key)); 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; return body;
@ -202,8 +219,22 @@ export async function signApplication(request) {
} }
const saved = await db('applications').where({ id: application.id }).first(); 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);
}
} }
/** /**

View File

@ -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<string, unknown>} 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<string, unknown>} 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,
};
}

View File

@ -4,8 +4,13 @@ import { db } from '../db/knex.js';
import { auditFromRequest } from '../middleware/audit.js'; import { auditFromRequest } from '../middleware/audit.js';
import { draftApplicationSchema, finalizeApplicationSchema } from '../schemas/application.js'; import { draftApplicationSchema, finalizeApplicationSchema } from '../schemas/application.js';
import { decryptField, encryptField, panLookup } from './fieldCrypto.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 { generateToken, hashToken } from './tokens.js';
import { sendResumeLink } from './mail.js'; import { sendResumeLink } from './mail.js';
import { readUserId } from './userAuth.js';
const CHEQUE_LIMIT = 5 * 1024 * 1024;
const CODE_FIELDS = ['pan', 'ifsc', 'gstin']; 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 * @param {import('@hapi/hapi').Request} request
*/ */
export async function applicationFromRequest(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 header = request.headers.authorization ?? '';
const match = header.match(/^Bearer\s+(\S+)$/i); const match = header.match(/^Bearer\s+(\S+)$/i);
if (!match) { if (!match) {
throw Boom.unauthorized('This resume link is invalid or has expired'); throw Boom.unauthorized('Authentication required');
} }
const tokenRow = await db('resume_tokens') 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 {import('@hapi/hapi').Request} request
* @param {Record<string, unknown>} application * @param {Record<string, unknown>} application
* @param {Record<string, unknown>} payload * @param {Record<string, unknown>} payload
* @param {boolean} finalize * @param {boolean} finalize
* @returns {Promise<Record<string, unknown>>}
*/ */
export async function saveApplication(request, application, payload, finalize) { export async function saveApplication(request, application, payload, finalize) {
if (application.status === 'SUBMITTED' || application.status === 'APPROVED') { if (application.status === 'SUBMITTED' || application.status === 'APPROVED') {
@ -204,13 +248,36 @@ export async function saveApplication(request, application, payload, finalize) {
/** @type {Record<string, unknown>} */ /** @type {Record<string, unknown>} */
const updates = {}; const updates = {};
const chequePayload = parsed.data.cancelled_cheque;
for (const [key, value] of Object.entries(parsed.data)) { for (const [key, value] of Object.entries(parsed.data)) {
if (value !== undefined) { if (value !== undefined && key !== 'cancelled_cheque') {
updates[key] = value; 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) { if (finalize) {
const merged = { const merged = {
...presentApplication(application), ...presentApplication(application),
@ -284,6 +351,5 @@ export async function saveApplication(request, application, payload, finalize) {
metadata: { finalize }, metadata: { finalize },
}); });
const saved = await db('applications').where({ id: application.id }).first(); return db('applications').where({ id: application.id }).first();
return presentApplication(saved);
} }

View File

@ -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<string, unknown>} 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);
}

View File

@ -53,11 +53,23 @@ function layout(content) {
/** /**
* @param {string} name * @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) { export function renderEmail(name, data) {
const physician = data.name || 'Doctor'; 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') { if (name === 'resume_link') {
return layout({ return layout({
subject: 'Continue your agreement', subject: 'Continue your agreement',

View File

@ -5,7 +5,7 @@ import { renderEmail } from './emailTemplates.js';
import { getMailFromName } from './settings.js'; import { getMailFromName } from './settings.js';
import { getObject } from './storage.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 const outbox = [];
export function clearOutbox() { export function clearOutbox() {
@ -102,7 +102,7 @@ async function deliverToSendGrid(message) {
* Send one template. Failures are stored on email_log and are not rethrown. * Send one template. Failures are stored on email_log and are not rethrown.
* @param {string} name * @param {string} name
* @param {string} to * @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) { export async function sendTemplate(name, to, data) {
/** @type {number | undefined} */ /** @type {number | undefined} */
@ -137,6 +137,7 @@ export async function sendTemplate(name, to, data) {
url: data.url ?? '', url: data.url ?? '',
applicationId: data.applicationId, applicationId: data.applicationId,
template: name, 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 * @param {{ applicationId: number, toEmail: string, token: string }} message
*/ */

View File

@ -42,3 +42,41 @@ export function readPngDataUrl(image, maxBytes, sizeMessage) {
return buffer; 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' };
}

View File

@ -16,7 +16,7 @@ const LOCAL_ROOT = path.resolve(
path.dirname(fileURLToPath(import.meta.url)), path.dirname(fileURLToPath(import.meta.url)),
'../../.local-storage', '../../.local-storage',
); );
const URL_TTL_MS = 5 * 60 * 1000; const URL_TTL_MS = 60 * 60 * 1000;
/** @type {S3Client | undefined} */ /** @type {S3Client | undefined} */
let s3; let s3;

View File

@ -1,7 +1,7 @@
import Boom from '@hapi/boom'; import Boom from '@hapi/boom';
import { db } from '../db/knex.js'; import { db } from '../db/knex.js';
import { recordAudit } from './audit.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 { knownAnswers, validateAnswers } from '../survey/validate.js';
import { stripHtmlDeep } from './sanitize.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 * @param {string} token
*/ */

View File

@ -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<string, unknown>} 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<string, unknown> | 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,
};
}

View File

@ -1,11 +1,11 @@
export const SURVEY_TITLE = 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 = [ const packRows = [
{ id: 'regular_pmdi', label: 'Regular pMDI' }, { id: 'regular_pmdi', label: 'Regular Pack — Formoterol/Budesonide 200 & 400 mcg pMDI (120 doses)' },
{ id: 'regular_rotacap', label: 'Regular Rotacap' }, { id: 'regular_rotacap', label: 'Regular Pack — Rotacap (pack of 30)' },
{ id: 'extended_pmdi', label: 'Extended-Dose pMDI' }, { id: 'extended_pmdi', label: 'Extended-Dose Pack — Formoterol/Budesonide 200 & 400 mcg pMDI (180 doses)' },
{ id: 'extended_rotacap', label: 'Extended-Dose Rotacap' }, { id: 'extended_rotacap', label: 'Extended-Dose Pack — Rotacap (pack of 60)' },
]; ];
const packColumns = [ const packColumns = [
@ -21,17 +21,19 @@ const likertScale = [
{ id: 'strongly_agree', label: 'Strongly Agree' }, { id: 'strongly_agree', label: 'Strongly Agree' },
]; ];
/** Version 1 of the adherence pack questionnaire. */ /** Survey Questionnaire Version 01 (14/10/2026). */
export const surveyDefinition = { export const surveyDefinition = {
version: '01',
versionDate: '2026-10-14',
sections: [ sections: [
{ {
id: 'demographics', id: 'demographics',
title: 'Basic Demographics / Doctor details', title: 'Basic Demographics / Doctor Details',
questions: [ questions: [
{ {
id: 'subtitle', id: 'subtitle',
type: 'help', 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 }, { id: 'name', type: 'short_text', label: 'Name', required: false },
{ {
@ -42,13 +44,11 @@ export const surveyDefinition = {
other: true, other: true,
options: [ options: [
{ id: 'mbbs', label: 'MBBS' }, { id: 'mbbs', label: 'MBBS' },
{ id: 'md_chest', label: 'MD Chest' }, { id: 'md_chest', label: 'MD (Chest)' },
{ id: 'dm_pulmo', label: 'DM Pulmo' }, { id: 'dm_pulmo_dnb_dch', label: 'DM (Pulmo / DNB / DCH)' },
{ id: 'dnb', label: 'DNB' }, { id: 'md_med', label: 'MD (Med)' },
{ id: 'd3ch', label: 'D3CH' }, { id: 'dnb_med', label: 'DNB (Med)' },
{ id: 'md_med', label: 'MD Med' }, { id: 'other', label: 'Any other (specify)' },
{ id: 'dnb_med', label: 'DNB Med' },
{ id: 'other', label: 'Any other, please specify' },
], ],
}, },
{ {
@ -80,8 +80,7 @@ export const surveyDefinition = {
options: [ options: [
{ id: 'chest', label: 'Chest Physician' }, { id: 'chest', label: 'Chest Physician' },
{ id: 'consulting', label: 'Consulting Physician' }, { id: 'consulting', label: 'Consulting Physician' },
{ id: 'general', label: 'General Physician' }, { id: 'other', label: 'Other (specify)' },
{ id: 'other', label: 'Other (please specify)' },
], ],
}, },
{ id: 'city', type: 'short_text', label: 'City', required: false }, { id: 'city', type: 'short_text', label: 'City', required: false },
@ -90,12 +89,12 @@ export const surveyDefinition = {
}, },
{ {
id: 'practice', id: 'practice',
title: 'Physician practice profile', title: 'Physician Practice Profile',
questions: [ questions: [
{ {
id: 'patients_per_month', id: 'patients_per_month',
type: 'number', 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, required: true,
integer: true, integer: true,
min: 0, min: 0,
@ -151,13 +150,13 @@ export const surveyDefinition = {
}, },
{ {
id: 'prescribing', id: 'prescribing',
title: 'Foracort prescribing pattern', title: 'Foracort Prescribing Pattern',
questions: [ questions: [
{ {
id: 'asthma_formoterol_pct', id: 'asthma_formoterol_pct',
type: 'percentage', type: 'percentage',
label: label:
'Approximately what proportion of your asthma patients receive Formoterol/Budesonide', 'Approximately what proportion of your asthma patients receive Formoterol/Budesonide?',
required: true, required: true,
min: 0, min: 0,
max: 100, max: 100,
@ -166,16 +165,28 @@ export const surveyDefinition = {
id: 'copd_formoterol_pct', id: 'copd_formoterol_pct',
type: 'percentage', type: 'percentage',
label: label:
'Approximately what proportion of your COPD patients receive Formoterol/Budesonide', 'Approximately what proportion of your COPD patients receive Formoterol/Budesonide?',
required: true, required: true,
min: 0, min: 0,
max: 100, 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', id: 'pack_intro',
type: 'section_header', type: 'section_header',
label: 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', id: 'pack_split',
@ -188,31 +199,26 @@ export const surveyDefinition = {
max: 100, max: 100,
sumTo: 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', id: 'pack_note_cost',
type: 'help', 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', id: 'adherence',
title: 'Physician assessment of adherence', title: 'Physician Assessment of Adherence',
questions: [ questions: [
{ {
id: 'adherence_prompt', id: 'adherence_prompt',
type: 'help', 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', id: 'adherence_estimate',
@ -230,7 +236,7 @@ export const surveyDefinition = {
}, },
{ {
id: 'comparison', id: 'comparison',
title: 'Physician assessment of adherence', title: 'Physician Assessment of Adherence',
questions: [ questions: [
{ {
id: 'adherence_comparison', id: 'adherence_comparison',
@ -251,13 +257,13 @@ export const surveyDefinition = {
}, },
{ {
id: 'barriers', id: 'barriers',
title: 'Reasons for non-adherence', title: 'Reasons for Non-adherence',
questions: [ questions: [
{ {
id: 'barriers', id: 'barriers',
type: 'multi_choice', type: 'multi_choice',
label: 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, required: true,
other: true, other: true,
options: [ options: [
@ -276,27 +282,27 @@ export const surveyDefinition = {
{ id: 'remembering', label: 'Difficulty remembering to take doses' }, { id: 'remembering', label: 'Difficulty remembering to take doses' },
{ id: 'technique', label: 'Inhaler technique difficulties' }, { id: 'technique', label: 'Inhaler technique difficulties' },
{ id: 'lifestyle', label: 'Patient lifestyle/work-related factors' }, { id: 'lifestyle', label: 'Patient lifestyle/work-related factors' },
{ id: 'other', label: 'Other (Specify)' }, { id: 'other', label: 'Other (specify)' },
], ],
}, },
], ],
}, },
{ {
id: 'benefit', id: 'benefit',
title: 'Patient characteristics associated with adherence', title: 'Patient Characteristics Associated with Adherence',
questions: [ questions: [
{ {
id: 'benefit', id: 'benefit',
type: 'multi_choice', type: 'multi_choice',
label: 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, required: true,
other: true, other: true,
options: [ options: [
{ id: 'elderly', label: 'Elderly patients' }, { id: 'elderly', label: 'Elderly patients' },
{ {
id: 'access', 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: 'exacerbations', label: 'Patients with frequent exacerbations' },
{ id: 'forgetting', label: 'Patients frequently forgetting doses' }, { id: 'forgetting', label: 'Patients frequently forgetting doses' },
@ -306,7 +312,7 @@ export const surveyDefinition = {
id: 'maintenance', id: 'maintenance',
label: 'All patients requiring long-term maintenance therapy', 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: 'travellers', label: 'Frequent travellers' },
{ id: 'caregiver', label: 'Caregiver-dependent patients' }, { id: 'caregiver', label: 'Caregiver-dependent patients' },
{ id: 'other', label: 'Other' }, { id: 'other', label: 'Other' },
@ -316,13 +322,13 @@ export const surveyDefinition = {
}, },
{ {
id: 'perspective', id: 'perspective',
title: 'Physician perspective on the Extended-Dose Pack', title: 'Physician Perspective on the Extended-Dose Pack',
questions: [ questions: [
{ {
id: 'perspective_intro', id: 'perspective_intro',
type: 'section_header', type: 'section_header',
label: 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: 'advantages', type: 'long_text', label: 'Advantages', required: true },
{ id: 'disadvantages', type: 'long_text', label: 'Disadvantages', required: true }, { id: 'disadvantages', type: 'long_text', label: 'Disadvantages', required: true },
@ -336,13 +342,13 @@ export const surveyDefinition = {
id: 'likert_intro', id: 'likert_intro',
type: 'section_header', type: 'section_header',
label: 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', id: 'likert',
type: 'likert_matrix', type: 'likert_matrix',
label: 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, required: true,
scale: likertScale, scale: likertScale,
rows: [ rows: [

View File

@ -1,20 +1,34 @@
import { db } from '../db/knex.js'; import { db } from '../db/knex.js';
import { SURVEY_TITLE, surveyDefinition } from './definition.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] * @param {import('knex').Knex | import('knex').Knex.Transaction} [connection]
*/ */
export async function seedSurveyDefinition(connection = db) { 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) { 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; return existing.id;
} }
await connection('survey_definitions').where({ is_active: true }).update({ is_active: false }); await connection('survey_definitions').where({ is_active: true }).update({ is_active: false });
const [id] = await connection('survey_definitions').insert({ const [id] = await connection('survey_definitions').insert({
version: 1, version: SURVEY_DEFINITION_VERSION,
title: SURVEY_TITLE, title: SURVEY_TITLE,
definition: JSON.stringify(surveyDefinition), definition: JSON.stringify(surveyDefinition),
is_active: true, is_active: true,

View File

@ -25,12 +25,16 @@ describe('agreement HTML', () => {
draft: true, 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('<p class="center title"><strong>CONSULTATION AGREEMENT</strong></p>'); expect(html).toContain('<p class="center title"><strong>CONSULTATION AGREEMENT</strong></p>');
expect(html).toContain('<p class="center"><strong>BETWEEN</strong></p>'); expect(html).toContain('<p class="center"><strong>BETWEEN</strong></p>');
expect(html).toContain('<p class="center"><strong>… PARTY OF THE FIRST PART</strong></p>'); expect(html).toContain('<p class="center"><strong>… PARTY OF THE FIRST PART</strong></p>');
expect(html).toContain('<p><strong>1. PURPOSE</strong></p>'); expect(html).toContain('<p><strong>1. PURPOSE</strong></p>');
expect(html).toContain('<strong>WHEREAS</strong>, CREST Private Limited'); expect(html).toContain(
'<strong>WHEREAS</strong>, Chest Research And Training Private Limited',
);
expect(html).toContain('Extended-Dose Pack versus Regular Pack');
expect(html).toContain('<strong>AND WHEREAS</strong>, the said study'); expect(html).toContain('<strong>AND WHEREAS</strong>, the said study');
expect(html).not.toContain('<strong>1.1 CREST'); expect(html).not.toContain('<strong>1.1 CREST');
expect(html).toContain('.filled { font-weight: 700; text-decoration: underline;'); expect(html).toContain('.filled { font-weight: 700; text-decoration: underline;');
@ -66,6 +70,8 @@ describe('agreement HTML', () => {
expect(html).toContain('<span class="filled">Dr Sudeep Salvi</span>'); expect(html).toContain('<span class="filled">Dr Sudeep Salvi</span>');
expect(html).toContain('<span class="filled">Medical Director</span>'); expect(html).toContain('<span class="filled">Medical Director</span>');
expect(html).toContain('data:image/png;base64,AAAA'); 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('Pending approval');
expect(html).not.toContain('DRAFT - NOT SIGNED'); expect(html).not.toContain('DRAFT - NOT SIGNED');
}); });

View File

@ -269,6 +269,11 @@ describe('applications', () => {
}); });
expect(missingConsent.statusCode).toBe(400); 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({ const signed = await api.inject({
method: 'POST', method: 'POST',
url: '/api/applications/me/sign', url: '/api/applications/me/sign',
@ -277,6 +282,7 @@ describe('applications', () => {
}); });
expect(signed.statusCode).toBe(200); 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).toMatchObject({ status: 'SUBMITTED' });
expect(signed.result.signedPdfUrl).toEqual(expect.any(String)); expect(signed.result.signedPdfUrl).toEqual(expect.any(String));
const row = await db('applications').where({ email }).first(); const row = await db('applications').where({ email }).first();
@ -284,6 +290,48 @@ describe('applications', () => {
expect(row.submitted_at).toBeTruthy(); 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 () => { it('returns a rejected application to draft from edit and keeps the reason', async () => {
const api = await start(); const api = await start();
await postEmail(api); await postEmail(api);

View File

@ -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);
});
});

View File

@ -163,9 +163,9 @@ describe('agreement email', () => {
const first = await api.inject({ method: 'GET', url: `/api/survey/${token}` }); const first = await api.inject({ method: 'GET', url: `/api/survey/${token}` });
const second = 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.statusCode).toBe(200);
expect(first.result.version).toBe(1); expect(first.result.version).toBe(2);
expect(second.statusCode).toBe(200); 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() }); await db('survey_tokens').where({ application_id: id }).update({ used_at: new Date() });
const used = await api.inject({ method: 'GET', url: `/api/survey/${token}` }); const used = await api.inject({ method: 'GET', url: `/api/survey/${token}` });

View File

@ -1,5 +1,5 @@
import { EventEmitter } from 'node:events'; 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'; import { beforeEach, describe, expect, it, vi } from 'vitest';
const spawn = vi.hoisted(() => vi.fn()); const spawn = vi.hoisted(() => vi.fn());
@ -38,12 +38,39 @@ describe('renderPdf', () => {
expect(spawn).toHaveBeenCalledWith( expect(spawn).toHaveBeenCalledWith(
'weasyprint', 'weasyprint',
expect.any(Array), 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]; const htmlPath = spawn.mock.calls[0][1][0];
await expect(access(htmlPath)).rejects.toThrow(); 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(
`<html><body><img src="data:image/png;base64,${tiny}" alt="sig" /></body></html>`,
);
expect(writtenHtml).toContain('src="embed-0.png"');
expect(writtenHtml).not.toContain('data:image/png;base64');
});
it('reports a missing WeasyPrint binary', async () => { it('reports a missing WeasyPrint binary', async () => {
spawn.mockImplementation(() => { spawn.mockImplementation(() => {
const proc = child(); const proc = child();

View File

@ -14,7 +14,7 @@ describe('local storage', () => {
await deleteObject(key); 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')); await putObject(key, Buffer.from('hello'));
expect((await getObject(key)).toString()).toBe('hello'); expect((await getObject(key)).toString()).toBe('hello');
@ -24,8 +24,8 @@ describe('local storage', () => {
const signature = url.searchParams.get('sig'); const signature = url.searchParams.get('sig');
expect(url.pathname).toContain(encodeURIComponent(key)); expect(url.pathname).toContain(encodeURIComponent(key));
expect(Number(expires) - Date.now()).toBeGreaterThan(4 * 60 * 1000); expect(Number(expires) - Date.now()).toBeGreaterThan(55 * 60 * 1000);
expect(Number(expires) - Date.now()).toBeLessThanOrEqual(5 * 60 * 1000); expect(Number(expires) - Date.now()).toBeLessThanOrEqual(60 * 60 * 1000);
expect(verifyLocalSignature(key, expires, signature)).toBe(true); expect(verifyLocalSignature(key, expires, signature)).toBe(true);
expect(verifyLocalSignature(key, String(Date.now() - 1000), signature)).toBe(false); expect(verifyLocalSignature(key, String(Date.now() - 1000), signature)).toBe(false);
}); });

View File

@ -258,7 +258,7 @@ describe('survey submission', () => {
const { applicationId, token } = await issueToken(); const { applicationId, token } = await issueToken();
const opened = await api.inject({ method: 'GET', url: `/api/survey/${token}` }); const opened = await api.inject({ method: 'GET', url: `/api/survey/${token}` });
expect(opened.statusCode).toBe(200); 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'); expect(opened.result.answers.name).toBe('Asha Rao');
const saved = await api.inject({ const saved = await api.inject({
@ -270,15 +270,15 @@ describe('survey submission', () => {
expect(saved.result.answers.city).toBe('Pune'); expect(saved.result.answers.city).toBe('Pune');
expect(saved.result.answers.ignored).toBeUndefined(); 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({ await db('survey_definitions').insert({
version: 2, version: 3,
title: 'Replacement survey', title: 'Replacement survey',
definition: JSON.stringify({ sections: [] }), definition: JSON.stringify({ sections: [] }),
is_active: true, is_active: true,
}); });
const pinned = await api.inject({ method: 'GET', url: `/api/survey/${token}` }); 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'); expect(pinned.result.answers.city).toBe('Pune');
const answers = completeAnswers(surveyDefinition); const answers = completeAnswers(surveyDefinition);

View File

@ -83,8 +83,11 @@ describe('survey export', () => {
await workbook.xlsx.load(response.rawPayload); await workbook.xlsx.load(response.rawPayload);
const responses = workbook.getWorksheet('Responses'); const responses = workbook.getWorksheet('Responses');
const headers = responses.getRow(1).values.slice(1); const headers = responses.getRow(1).values.slice(1);
expect(headers).toContain('Prescribing % - Extended-Dose Rotacap - COPD'); expect(headers).toContain(
const column = headers.indexOf('Prescribing % - Extended-Dose Rotacap - COPD') + 1; '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); expect(responses.getRow(2).getCell(column).value).toBe(25);
const questions = workbook.getWorksheet('Questions'); const questions = workbook.getWorksheet('Questions');
const questionIds = questions.getColumn(1).values; const questionIds = questions.getColumn(1).values;

View File

@ -8,7 +8,10 @@ const TABLES = [
'survey_definitions', 'survey_definitions',
'signatures', 'signatures',
'resume_tokens', 'resume_tokens',
'consents',
'login_otps',
'applications', 'applications',
'users',
'admin_users', 'admin_users',
]; ];

View File

@ -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);
});
});

View File

@ -1,10 +1,10 @@
CONSULTATION AGREEMENT 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 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); (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." 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. 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.

65
files/docs/pis.md Normal file
View File

@ -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

View File

@ -8,6 +8,7 @@
}, },
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"dev:remote": "API_PROXY_TARGET=https://pppaimt-crest.xltr.in vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"test": "vitest run" "test": "vitest run"

View File

@ -15,9 +15,13 @@ import ReviewPage from './pages/ReviewPage.jsx';
import SignPage from './pages/SignPage.jsx'; import SignPage from './pages/SignPage.jsx';
import SubmittedPage from './pages/SubmittedPage.jsx'; import SubmittedPage from './pages/SubmittedPage.jsx';
import SurveyPage from './pages/SurveyPage.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 SiteFooter from './SiteFooter.jsx';
import SiteHeader from './SiteHeader.jsx'; import SiteHeader from './SiteHeader.jsx';
import TokenReferrer from './TokenReferrer.jsx'; import TokenReferrer from './TokenReferrer.jsx';
import UserLayout from './user/UserLayout.jsx';
function PublicShell({ children }) { function PublicShell({ children }) {
return ( return (
@ -32,14 +36,16 @@ function PublicShell({ children }) {
export default function App() { export default function App() {
return ( return (
<Routes> <Routes>
<Route <Route path="/" element={<Navigate to="/login" replace />} />
path="/" <Route path="/login" element={<UserLoginPage />} />
element={ <Route element={<UserLayout />}>
<PublicShell> <Route path="/dashboard" element={<UserDashboardPage />} />
<AgreementPage /> <Route path="/consent" element={<ConsentPage />} />
</PublicShell> <Route path="/agreement" element={<AgreementPage />} />
} <Route path="/review" element={<ReviewPage />} />
/> <Route path="/sign" element={<SignPage />} />
<Route path="/submitted" element={<SubmittedPage />} />
</Route>
<Route <Route
path="/privacy" path="/privacy"
element={ element={
@ -48,7 +54,7 @@ export default function App() {
</PublicShell> </PublicShell>
} }
/> />
<Route path="/start" element={<Navigate to="/" replace />} /> <Route path="/start" element={<Navigate to="/login" replace />} />
<Route <Route
path="/resume/:token" path="/resume/:token"
element={ element={
@ -58,38 +64,6 @@ export default function App() {
</PublicShell> </PublicShell>
} }
/> />
<Route
path="/agreement"
element={
<PublicShell>
<AgreementPage />
</PublicShell>
}
/>
<Route
path="/review"
element={
<PublicShell>
<ReviewPage />
</PublicShell>
}
/>
<Route
path="/sign"
element={
<PublicShell>
<SignPage />
</PublicShell>
}
/>
<Route
path="/submitted"
element={
<PublicShell>
<SubmittedPage />
</PublicShell>
}
/>
<Route <Route
path="/survey/:token" path="/survey/:token"
element={ element={

View File

@ -1,11 +1,24 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, screen } from '@testing-library/react'; import { render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
import { describe, expect, it } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import App from './App.jsx'; import App from './App.jsx';
describe('home route', () => { describe('home route', () => {
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(); const queryClient = new QueryClient();
render( render(
@ -16,9 +29,60 @@ describe('home route', () => {
</QueryClientProvider>, </QueryClientProvider>,
); );
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.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(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={['/dashboard']}>
<App />
</MemoryRouter>
</QueryClientProvider>,
);
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',
);
}); });
}); });

View File

@ -27,8 +27,14 @@ export async function api(path, options = {}) {
const response = await fetch(path, { const response = await fetch(path, {
method: options.method ?? 'GET', method: options.method ?? 'GET',
headers, headers,
credentials: 'include',
body: options.body ? JSON.stringify(options.body) : undefined, body: options.body ? JSON.stringify(options.body) : undefined,
}); });
if (response.status === 204) {
return null;
}
const data = await response.json().catch(() => ({})); const data = await response.json().catch(() => ({}));
if (!response.ok) { if (!response.ok) {

View File

@ -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}`;
}

View File

@ -91,6 +91,12 @@ body {
color: #44403c; color: #44403c;
} }
.site label:has(input[type='radio']),
.site label:has(input[type='checkbox']),
.survey-choice {
font-size: 0.9rem;
}
.site input, .site input,
.site textarea, .site textarea,
.site button, .site button,
@ -187,6 +193,9 @@ a:focus-visible {
.site-brand span { .site-brand span {
font-size: 1.4rem; font-size: 1.4rem;
} }
.user-brand span {
font-size: 1.4rem;
}
} }
.site main { .site main {
@ -275,18 +284,23 @@ a:focus-visible {
color: var(--brand); color: var(--brand);
} }
.site button { .site button,
.site a.button {
display: inline-block;
padding: 0.7rem 1.15rem; padding: 0.7rem 1.15rem;
border: 0; border: 0;
border-radius: 0.55rem; border-radius: 0.55rem;
background: linear-gradient(135deg, var(--brand) 0%, #1578c8 100%); background: linear-gradient(135deg, var(--brand) 0%, #1578c8 100%);
color: #f0fdfa; color: #f0fdfa;
font-weight: 600; font-weight: 600;
text-decoration: none;
cursor: pointer; cursor: pointer;
} }
.site button:hover:not(:disabled) { .site button:hover:not(:disabled),
.site a.button:hover {
background: linear-gradient(135deg, #0d6a63 0%, #1d4ed8 100%); background: linear-gradient(135deg, #0d6a63 0%, #1d4ed8 100%);
color: #f0fdfa;
} }
.site button:disabled { .site button:disabled {
@ -433,7 +447,80 @@ a:focus-visible {
} }
.error { .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 { .site main.wide {
@ -791,3 +878,346 @@ a:focus-visible {
clip: auto; 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;
}
}

View File

@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useForm } from 'react-hook-form'; 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 agreementText from '../../../files/docs/agreement.md?raw';
import { api, getResumeToken, saveResumeToken } from '../api.js'; import { api, getResumeToken, saveResumeToken } from '../api.js';
import FlowSteps from '../FlowSteps.jsx'; import FlowSteps from '../FlowSteps.jsx';
@ -55,13 +55,145 @@ export function upperValue(value) {
return value.toUpperCase(); 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 (
<p className="field-error" role="alert">
* {message}
</p>
);
}
/**
* @param {{ text: string }} props
*/
function FieldTip({ text }) {
return (
<span className="field-tip" title={text} tabIndex={0} aria-label={text}>
?
</span>
);
}
/**
* @param {File} file
* @returns {Promise<string>}
*/
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() { export default function AgreementPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const me = useOutletContext();
const sessionEmail = me?.user?.email || '';
const [application, setApplication] = useState(null); const [application, setApplication] = useState(null);
const [loadError, setLoadError] = useState(''); const [loadError, setLoadError] = useState('');
const [saveError, setSaveError] = useState(''); const [saveError, setSaveError] = useState('');
/** @type {[Record<string, string>, Function]} */
const [fieldErrors, setFieldErrors] = useState({});
const [status, setStatus] = useState(''); const [status, setStatus] = useState('');
const [ready, setReady] = useState(false); 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 { const {
register, register,
reset, reset,
@ -76,9 +208,40 @@ export default function AgreementPage() {
const locked = readOnly || needsEdit; const locked = readOnly || needsEdit;
const lockedRef = useRef(locked); const lockedRef = useRef(locked);
lockedRef.current = 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(() => { useEffect(() => {
if (!getResumeToken()) { if (!hasSession && !getResumeToken()) {
reset({ reset({
...EMPTY, ...EMPTY,
agreement_date: today(), agreement_date: today(),
@ -96,9 +259,11 @@ export default function AgreementPage() {
} }
setApplication(loaded); setApplication(loaded);
setChequeUrl(loaded.cancelledChequeUrl || '');
reset({ reset({
...EMPTY, ...EMPTY,
...loaded, ...loaded,
email: loaded.email || sessionEmail,
agreement_date: loaded.agreement_date || today(), agreement_date: loaded.agreement_date || today(),
age: loaded.age ?? '', age: loaded.age ?? '',
gstin: loaded.gstin ?? '', gstin: loaded.gstin ?? '',
@ -115,7 +280,7 @@ export default function AgreementPage() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [reset]); }, [hasSession, reset, sessionEmail]);
useEffect(() => { useEffect(() => {
if (!ready) { if (!ready) {
@ -123,7 +288,7 @@ export default function AgreementPage() {
} }
const timer = setInterval(() => { const timer = setInterval(() => {
if (lockedRef.current || !getResumeToken()) { if (lockedRef.current || (!hasSession && !getResumeToken())) {
return; return;
} }
@ -134,15 +299,21 @@ export default function AgreementPage() {
} }
api('/api/applications/me', { method: 'PUT', body: payload }) api('/api/applications/me', { method: 'PUT', body: payload })
.then(() => setStatus('Draft saved')) .then(() => {
.catch((error) => setSaveError(error.message)); setStatus('Draft saved');
setSaveError('');
})
.catch((error) => {
setStatus('');
applyErrorMessage(error.message);
});
}, 10000); }, 10000);
return () => clearInterval(timer); return () => clearInterval(timer);
}, [dirtyFields, getValues, ready]); }, [dirtyFields, getValues, hasSession, ready]);
async function ensureDraft() { async function ensureDraft() {
if (getResumeToken()) { if (hasSession || getResumeToken()) {
return; return;
} }
@ -166,6 +337,7 @@ export default function AgreementPage() {
async function continueToReview() { async function continueToReview() {
setSaveError(''); setSaveError('');
setFieldErrors({});
const values = getValues(); const values = getValues();
const parsed = applicationSchema.safeParse({ const parsed = applicationSchema.safeParse({
...values, ...values,
@ -174,7 +346,22 @@ export default function AgreementPage() {
}); });
if (!parsed.success) { if (!parsed.success) {
setSaveError(parsed.error.issues[0].message); /** @type {Record<string, string>} */
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; return;
} }
@ -186,7 +373,7 @@ export default function AgreementPage() {
await api('/api/applications/me?finalize=true', { method: 'PUT', body: fields }); await api('/api/applications/me?finalize=true', { method: 'PUT', body: fields });
navigate('/review'); navigate('/review');
} catch (error) { } catch (error) {
setSaveError(error.message); applyErrorMessage(error.message);
} }
} }
@ -196,25 +383,97 @@ export default function AgreementPage() {
try { try {
const updated = await api('/api/applications/me/edit', { method: 'POST' }); const updated = await api('/api/applications/me/edit', { method: 'POST' });
setApplication(updated); setApplication(updated);
setChequeUrl(updated.cancelledChequeUrl || '');
setStatus(''); setStatus('');
} catch (error) { } catch (error) {
setSaveError(error.message); setSaveError(error.message);
} }
} }
/**
* @param {import('react').ChangeEvent<HTMLInputElement>} 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) { if (loadError) {
return ( return (
<main> <section className="agreement-page">
<h1>Agreement</h1> <h1>Agreement</h1>
<p className="error" role="alert"> <p className="error" role="alert">
{loadError} {loadError}
</p> </p>
</main> </section>
); );
} }
return ( return (
<main> <section className="agreement-page">
<h1>Agreement</h1> <h1>Agreement</h1>
<p className="page-lead">Read the terms, then enter your details to continue.</p> <p className="page-lead">Read the terms, then enter your details to continue.</p>
<FlowSteps current={readOnly ? 'done' : accepted ? 'details' : 'read'} /> <FlowSteps current={readOnly ? 'done' : accepted ? 'details' : 'read'} />
@ -252,94 +511,229 @@ export default function AgreementPage() {
</label> </label>
{accepted ? ( {accepted ? (
<> <>
<fieldset disabled={locked}> <fieldset disabled={locked}>
<legend>Agreement date</legend> <legend>Agreement date</legend>
<label htmlFor="agreement_date">Date</label> <label htmlFor="agreement_date">Date</label>
<input <input
id="agreement_date" id="agreement_date"
type="date" type="date"
readOnly={locked} readOnly={locked}
{...register('agreement_date')} {...register('agreement_date', {
/> onChange: () => clearFieldError('agreement_date'),
</fieldset> })}
<fieldset disabled={locked}> />
<legend>Physician details</legend> <FieldError message={fieldErrors.agreement_date} />
<label htmlFor="email">Email</label> </fieldset>
<input <fieldset disabled={locked}>
id="email" <legend>Physician details</legend>
type="email" <label htmlFor="email">Email</label>
autoComplete="email" <input
readOnly={locked} id="email"
{...register('email')} type="email"
/> autoComplete="email"
<label htmlFor="full_name">Full name</label> readOnly={locked || hasSession}
<input id="full_name" autoComplete="name" readOnly={locked} {...register('full_name')} /> {...register('email', { onChange: () => clearFieldError('email') })}
<label htmlFor="age">Age</label> />
<input id="age" type="number" min="21" max="100" readOnly={locked} {...register('age')} /> <FieldError message={fieldErrors.email} />
<label htmlFor="address">Address</label> <label htmlFor="full_name">Full name</label>
<textarea id="address" readOnly={locked} {...register('address')} /> <input
<label htmlFor="registration_no">Registration number</label> id="full_name"
<input id="registration_no" readOnly={locked} {...register('registration_no')} /> autoComplete="name"
<label htmlFor="medical_council">Medical council</label> readOnly={locked}
<input id="medical_council" readOnly={locked} {...register('medical_council')} /> {...register('full_name', { onChange: () => clearFieldError('full_name') })}
<label htmlFor="pan">PAN</label> />
<input <FieldError message={fieldErrors.full_name} />
id="pan" <label htmlFor="age">Age</label>
readOnly={locked} <input
{...register('pan')} id="age"
onChange={(event) => type="number"
setValue('pan', upperValue(event.target.value), { shouldDirty: true }) min="21"
} max="100"
/> readOnly={locked}
<label htmlFor="gstin">GSTIN (optional)</label> {...register('age', { onChange: () => clearFieldError('age') })}
<input />
id="gstin" <FieldError message={fieldErrors.age} />
readOnly={locked} <label htmlFor="address">Address</label>
{...register('gstin')} <textarea
onChange={(event) => id="address"
setValue('gstin', upperValue(event.target.value), { shouldDirty: true }) readOnly={locked}
} {...register('address', { onChange: () => clearFieldError('address') })}
/> />
</fieldset> <FieldError message={fieldErrors.address} />
<fieldset disabled={locked}> <label htmlFor="registration_no">Registration number</label>
<legend>Bank details</legend> <input
<label htmlFor="bank_name">Bank name</label> id="registration_no"
<input id="bank_name" readOnly={locked} {...register('bank_name')} /> readOnly={locked}
<label htmlFor="account_holder">Account holder name</label> {...register('registration_no', {
<input id="account_holder" readOnly={locked} {...register('account_holder')} /> onChange: () => clearFieldError('registration_no'),
<label htmlFor="account_number">Account number</label> })}
<input />
id="account_number" <FieldError message={fieldErrors.registration_no} />
inputMode="numeric" <label htmlFor="medical_council">Medical council</label>
readOnly={locked} <input
{...register('account_number')} id="medical_council"
/> readOnly={locked}
<label htmlFor="ifsc">IFSC</label> {...register('medical_council', {
<input onChange: () => clearFieldError('medical_council'),
id="ifsc" })}
readOnly={locked} />
{...register('ifsc')} <FieldError message={fieldErrors.medical_council} />
onChange={(event) => <label htmlFor="pan">PAN</label>
setValue('ifsc', upperValue(event.target.value), { shouldDirty: true }) <input
} id="pan"
/> readOnly={locked}
</fieldset> {...register('pan')}
{saveError ? ( onChange={(event) => {
<p className="error" role="alert"> clearFieldError('pan');
{saveError} setValue('pan', upperValue(event.target.value), { shouldDirty: true });
</p> }}
) : null} />
{status ? <p role="status">{status}</p> : null} <FieldError message={fieldErrors.pan} />
{readOnly ? null : ( <label htmlFor="gstin">GSTIN (optional)</label>
<div className="actions"> <input
<button type="submit" disabled={needsEdit}> id="gstin"
Continue to review readOnly={locked}
</button> {...register('gstin')}
</div> onChange={(event) => {
)} clearFieldError('gstin');
setValue('gstin', upperValue(event.target.value), { shouldDirty: true });
}}
/>
<FieldError message={fieldErrors.gstin} />
</fieldset>
<fieldset disabled={locked}>
<legend>Bank details</legend>
<label htmlFor="bank_name">
Bank name <FieldTip text={BANK_TIPS.bank_name} />
</label>
<input
id="bank_name"
readOnly={locked}
aria-describedby="tip-bank_name"
{...register('bank_name', { onChange: () => clearFieldError('bank_name') })}
/>
<p id="tip-bank_name" className="field-hint">
{BANK_TIPS.bank_name}
</p>
<FieldError message={fieldErrors.bank_name} />
<label htmlFor="account_holder">
Account holder name <FieldTip text={BANK_TIPS.account_holder} />
</label>
<input
id="account_holder"
readOnly={locked}
aria-describedby="tip-account_holder"
{...register('account_holder', {
onChange: () => clearFieldError('account_holder'),
})}
/>
<p id="tip-account_holder" className="field-hint">
{BANK_TIPS.account_holder}
</p>
<FieldError message={fieldErrors.account_holder} />
<label htmlFor="account_number">
Account number <FieldTip text={BANK_TIPS.account_number} />
</label>
<input
id="account_number"
inputMode="numeric"
readOnly={locked}
aria-describedby="tip-account_number"
{...register('account_number', {
onChange: () => clearFieldError('account_number'),
})}
/>
<p id="tip-account_number" className="field-hint">
{BANK_TIPS.account_number}
</p>
<FieldError message={fieldErrors.account_number} />
<label htmlFor="ifsc">
IFSC <FieldTip text={BANK_TIPS.ifsc} />
</label>
<input
id="ifsc"
readOnly={locked}
aria-describedby="tip-ifsc"
{...register('ifsc')}
onChange={(event) => {
clearFieldError('ifsc');
setValue('ifsc', upperValue(event.target.value), { shouldDirty: true });
}}
/>
<p id="tip-ifsc" className="field-hint">
{BANK_TIPS.ifsc}
</p>
<FieldError message={fieldErrors.ifsc} />
<label htmlFor="cancelled_cheque">
Cancelled cheque (optional) <FieldTip text={BANK_TIPS.cancelled_cheque} />
</label>
{chequeUrl ? (
<div className="cheque-preview">
<img
src={chequeUrl}
alt="Uploaded cancelled cheque"
className="cheque-image"
/>
{locked ? null : (
<button
type="button"
className="secondary cheque-remove"
disabled={chequeBusy}
onClick={removeCheque}
>
Remove
</button>
)}
{/*
<a href={chequeUrl} target="_blank" rel="noopener noreferrer">
View full size
</a>
*/}
</div>
) : (
<p className="field-hint">No cancelled cheque uploaded</p>
)}
{chequeBusy ? (
<p className="cheque-status" role="status">
Uploading cheque…
</p>
) : chequeStatus ? (
<p className="cheque-status" role="status">
{chequeStatus}
</p>
) : null}
<input
ref={chequeInputRef}
id="cancelled_cheque"
type="file"
accept="image/png,image/jpeg"
className="admin-file"
disabled={locked || chequeBusy}
aria-describedby="tip-cancelled_cheque"
onChange={onChequeSelected}
/>
<p id="tip-cancelled_cheque" className="field-hint">
{BANK_TIPS.cancelled_cheque}
</p>
<FieldError message={fieldErrors.cancelled_cheque} />
</fieldset>
{fieldErrors.accepted ? <FieldError message={fieldErrors.accepted} /> : null}
{saveError ? (
<p className="field-error" role="alert">
* {saveError}
</p>
) : null}
{status ? <p role="status">{status}</p> : null}
{readOnly ? null : (
<div className="actions">
<button type="submit" disabled={needsEdit}>
Continue to review
</button>
</div>
)}
</> </>
) : null} ) : null}
</form> </form>
</main> </section>
); );
} }

View File

@ -1,5 +1,5 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
import AgreementPage from './AgreementPage.jsx'; import AgreementPage from './AgreementPage.jsx';
@ -21,6 +21,12 @@ describe('agreement page', () => {
); );
}); });
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
sessionStorage.clear();
});
it('shows the form only after the agreement is accepted', async () => { it('shows the form only after the agreement is accepted', async () => {
sessionStorage.removeItem('resumeToken'); sessionStorage.removeItem('resumeToken');
render( render(
@ -56,4 +62,60 @@ describe('agreement page', () => {
expect(pan).toHaveValue('ABCDE1234F'); expect(pan).toHaveValue('ABCDE1234F');
}); });
}); });
it('shows registration conflicts under the registration field', async () => {
const fetchMock = vi.fn(async (path, options = {}) => {
if (
path === '/api/applications/me' &&
(!options.method || options.method === 'GET')
) {
return {
ok: true,
status: 200,
json: async () => ({
email: 'physician@example.com',
agreement_date: '2026-10-17',
full_name: 'Asha Rao',
age: 42,
address: 'Pune',
registration_no: 'MMC-1',
medical_council: 'Maharashtra Medical Council',
pan: 'ABCDE1234F',
bank_name: 'SBI',
account_holder: 'Asha Rao',
account_number: '123456789012',
ifsc: 'SBIN0001234',
accepted: true,
}),
};
}
if (String(path).startsWith('/api/applications/me') && options.method === 'PUT') {
return {
ok: false,
status: 409,
json: async () => ({
error: { message: 'This registration number is already in use' },
}),
};
}
return { ok: false, status: 404, json: async () => ({}) };
});
vi.stubGlobal('fetch', fetchMock);
render(
<MemoryRouter>
<AgreementPage />
</MemoryRouter>,
);
const registration = await screen.findByLabelText('Registration number');
fireEvent.change(registration, { target: { value: 'MMC-999' } });
fireEvent.click(screen.getAllByRole('button', { name: 'Continue to review' })[0]);
const message = await screen.findByText('* This registration number is already in use');
expect(message).toBeInTheDocument();
expect(message.className).toContain('field-error');
});
}); });

View File

@ -0,0 +1,205 @@
import { useEffect, useMemo, useState } from 'react';
import { useNavigate, useOutletContext } from 'react-router-dom';
import pisText from '../../../files/docs/pis.md?raw';
import {
CONSENT_QUESTIONS,
emptyConsentAnswers,
todayIsoDate,
} from '../consentQuestions.js';
import { pathForNextStep, userApi } from '../userApi.js';
function displayPis(text) {
return text
.replace(/^# .+\n+/m, '')
.replace(/^## /gm, '')
.replace(/^# /gm, '')
.replace(/\*\*/g, '')
.trim();
}
export default function ConsentPage() {
const navigate = useNavigate();
const me = useOutletContext();
const [participantName, setParticipantName] = useState(me?.user?.name || '');
const [consentDate, setConsentDate] = useState(todayIsoDate());
const [answers, setAnswers] = useState(emptyConsentAnswers);
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const [existing, setExisting] = useState(null);
const [loading, setLoading] = useState(true);
const pisBody = useMemo(() => displayPis(pisText), []);
useEffect(() => {
let cancelled = false;
userApi('/api/consent/me')
.then((consent) => {
if (cancelled) {
return;
}
if (consent?.id) {
setExisting(consent);
setParticipantName(consent.participant_name || '');
setConsentDate(consent.consent_date || todayIsoDate());
setAnswers({ ...emptyConsentAnswers(), ...(consent.answers || {}) });
}
})
.catch((loadError) => {
if (!cancelled) {
setError(loadError.message);
}
})
.finally(() => {
if (!cancelled) {
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (me?.steps?.consent?.done && me.nextStep && me.nextStep !== 'consent') {
navigate(pathForNextStep(me.nextStep), { replace: true });
}
}, [me, navigate]);
function toggleAnswer(id) {
setAnswers((current) => ({ ...current, [id]: !current[id] }));
setError('');
}
async function submit(event) {
event.preventDefault();
setError('');
const missing = CONSENT_QUESTIONS.find((question) => !answers[question.id]);
if (missing) {
setError('Please confirm all consent statements before continuing.');
return;
}
if (!participantName.trim()) {
setError('Enter your name.');
return;
}
setBusy(true);
try {
await userApi('/api/consent', {
method: 'POST',
body: {
participant_name: participantName.trim(),
consent_date: consentDate,
answers,
},
});
navigate('/agreement', { replace: true });
} catch (submitError) {
setError(submitError.message);
setBusy(false);
}
}
if (loading) {
return <p>Loading consent…</p>;
}
const readOnly = Boolean(existing);
return (
<section className="consent-page">
<h1>Participant information & consent</h1>
<p className="page-lead">
Read the participant information sheet, then complete the informed consent form to continue.
</p>
<h2>Participant Information Sheet (PIS)</h2>
<div className="agreement-text consent-pis" tabIndex={0} aria-label="Participant information sheet">
{pisBody}
</div>
<h2>Informed Consent Form (ICF)</h2>
<p>
I have read and understood the information provided above regarding this survey. I have had
the opportunity to ask questions and have received satisfactory answers to questions.
</p>
<form className="consent-form" onSubmit={submit}>
<fieldset disabled={readOnly || busy}>
<legend className="visually-hidden">Consent statements</legend>
<div className="consent-checks">
{CONSENT_QUESTIONS.map((question) => (
<label key={question.id} className="accept consent-check">
<input
type="checkbox"
checked={Boolean(answers[question.id])}
onChange={() => toggleAnswer(question.id)}
/>
<span>{question.label}</span>
</label>
))}
</div>
<div className="consent-fields">
<label htmlFor="participant-name">
Participant name
<input
id="participant-name"
type="text"
required
autoComplete="name"
value={participantName}
onChange={(event) => setParticipantName(event.target.value)}
/>
</label>
<label htmlFor="consent-date">
Date
<input
id="consent-date"
type="date"
required
value={consentDate}
onChange={(event) => setConsentDate(event.target.value)}
/>
</label>
</div>
</fieldset>
{error ? (
<p className="error" role="alert">
{error}
</p>
) : null}
{readOnly ? (
<p className="banner">
Consent was submitted on{' '}
{existing.submitted_at
? new Date(existing.submitted_at).toLocaleString()
: existing.consent_date}
. You can continue to the physician agreement.
</p>
) : null}
<div className="survey-actions">
{readOnly ? (
<button type="button" onClick={() => navigate('/agreement')}>
Continue to agreement
</button>
) : (
<button type="submit" disabled={busy}>
{busy ? 'Submitting…' : 'Submit your consent and proceed'}
</button>
)}
</div>
</form>
</section>
);
}

View File

@ -0,0 +1,95 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter, Outlet, Route, Routes } from 'react-router-dom';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { CONSENT_QUESTIONS } from '../consentQuestions.js';
import ConsentPage from './ConsentPage.jsx';
const me = {
user: { id: 1, email: 'doctor@example.com', name: null },
steps: {
consent: { done: false },
agreement: { done: false },
survey: { done: false, status: null },
},
nextStep: 'consent',
};
function Shell() {
return <Outlet context={me} />;
}
describe('ConsentPage', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('requires all consent boxes and submits', async () => {
const fetchMock = vi.fn(async (path, options = {}) => {
if (path === '/api/consent/me') {
return {
ok: true,
status: 204,
json: async () => {
throw new Error('Unexpected end of JSON input');
},
};
}
if (path === '/api/consent' && options.method === 'POST') {
return {
ok: true,
status: 200,
json: async () => ({
participant_name: 'Dr Test',
consent_date: '2026-10-16',
answers: {},
}),
};
}
return { ok: false, status: 404, json: async () => ({}) };
});
vi.stubGlobal('fetch', fetchMock);
render(
<MemoryRouter initialEntries={['/consent']}>
<Routes>
<Route element={<Shell />}>
<Route path="/consent" element={<ConsentPage />} />
</Route>
<Route path="/agreement" element={<p>Agreement next</p>} />
</Routes>
</MemoryRouter>,
);
expect(
await screen.findByRole('heading', { name: 'Participant information & consent' }),
).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('Participant name'), {
target: { value: 'Dr Test' },
});
fireEvent.click(screen.getByRole('button', { name: /Submit your consent/i }));
expect(await screen.findByRole('alert')).toHaveTextContent(/all consent statements/i);
for (const question of CONSENT_QUESTIONS) {
fireEvent.click(screen.getByLabelText(question.label));
}
fireEvent.click(screen.getByRole('button', { name: /Submit your consent/i }));
await waitFor(() => {
expect(screen.getByText('Agreement next')).toBeInTheDocument();
});
expect(fetchMock).toHaveBeenCalledWith(
'/api/consent',
expect.objectContaining({
method: 'POST',
credentials: 'include',
}),
);
});
});

View File

@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { api, getResumeToken } from '../api.js'; import { api } from '../api.js';
import FlowSteps from '../FlowSteps.jsx'; import FlowSteps from '../FlowSteps.jsx';
export default function ReviewPage() { export default function ReviewPage() {
@ -12,11 +12,6 @@ export default function ReviewPage() {
const locked = application?.status === 'SUBMITTED' || application?.status === 'APPROVED'; const locked = application?.status === 'SUBMITTED' || application?.status === 'APPROVED';
useEffect(() => { useEffect(() => {
if (!getResumeToken()) {
setError('Open the private link from your email to continue.');
return undefined;
}
let cancelled = false; let cancelled = false;
api('/api/applications/me') api('/api/applications/me')
@ -61,7 +56,7 @@ export default function ReviewPage() {
} }
return ( return (
<main className="wide"> <section className="agreement-page wide">
<h1>Review agreement</h1> <h1>Review agreement</h1>
<p className="page-lead">Check the preview before you sign.</p> <p className="page-lead">Check the preview before you sign.</p>
<FlowSteps current={locked ? 'done' : 'review'} /> <FlowSteps current={locked ? 'done' : 'review'} />
@ -95,6 +90,6 @@ export default function ReviewPage() {
</button> </button>
</div> </div>
)} )}
</main> </section>
); );
} }

View File

@ -1,9 +1,30 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import SignatureCanvas from 'react-signature-canvas'; import SignatureCanvas from 'react-signature-canvas';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { api, getResumeToken } from '../api.js'; import { api } from '../api.js';
import FlowSteps from '../FlowSteps.jsx'; import FlowSteps from '../FlowSteps.jsx';
/**
* Export the pad with a white background so WeasyPrint keeps ink visible.
* @param {{ getCanvas: () => HTMLCanvasElement, toDataURL: (type?: string) => string }} pad
*/
function drawnSignatureDataUrl(pad) {
const source = pad.getCanvas();
const exportCanvas = document.createElement('canvas');
exportCanvas.width = source.width;
exportCanvas.height = source.height;
const context = exportCanvas.getContext('2d');
if (!context) {
return pad.toDataURL('image/png');
}
context.fillStyle = '#ffffff';
context.fillRect(0, 0, exportCanvas.width, exportCanvas.height);
context.drawImage(source, 0, 0);
return exportCanvas.toDataURL('image/png');
}
export default function SignPage() { export default function SignPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const padRef = useRef(null); const padRef = useRef(null);
@ -14,11 +35,6 @@ export default function SignPage() {
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
useEffect(() => { useEffect(() => {
if (!getResumeToken()) {
setError('Open the private link from your email to continue.');
return undefined;
}
let cancelled = false; let cancelled = false;
api('/api/applications/me') api('/api/applications/me')
@ -60,7 +76,7 @@ export default function SignPage() {
return; return;
} }
body = { type: 'DRAWN', image: padRef.current.toDataURL('image/png'), consent: true }; body = { type: 'DRAWN', image: drawnSignatureDataUrl(padRef.current), consent: true };
} else { } else {
if (!typedName.trim()) { if (!typedName.trim()) {
setError('Type your signature.'); setError('Type your signature.');
@ -73,8 +89,14 @@ export default function SignPage() {
setSubmitting(true); setSubmitting(true);
try { try {
await api('/api/applications/me/sign', { method: 'POST', body }); const result = await api('/api/applications/me/sign', { method: 'POST', body });
navigate('/submitted');
if (typeof result.surveyToken === 'string' && result.surveyToken) {
navigate(`/survey/${result.surveyToken}`, { replace: true });
return;
}
navigate('/submitted', { replace: true });
} catch (signError) { } catch (signError) {
setError(signError.message); setError(signError.message);
setSubmitting(false); setSubmitting(false);
@ -82,7 +104,7 @@ export default function SignPage() {
} }
return ( return (
<main> <section className="agreement-page">
<h1>Sign the agreement</h1> <h1>Sign the agreement</h1>
<p className="page-lead">Draw your signature, or type it instead.</p> <p className="page-lead">Draw your signature, or type it instead.</p>
<FlowSteps current="sign" /> <FlowSteps current="sign" />
@ -147,6 +169,6 @@ export default function SignPage() {
{submitting ? 'Submitting…' : 'Submit signature'} {submitting ? 'Submitting…' : 'Submit signature'}
</button> </button>
</form> </form>
</main> </section>
); );
} }

View File

@ -1,17 +1,15 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { api, getResumeToken } from '../api.js'; import { Link } from 'react-router-dom';
import { api } from '../api.js';
import FlowSteps from '../FlowSteps.jsx'; import FlowSteps from '../FlowSteps.jsx';
import { userApi } from '../userApi.js';
export default function SubmittedPage() { export default function SubmittedPage() {
const [application, setApplication] = useState(null); const [application, setApplication] = useState(null);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [surveyBusy, setSurveyBusy] = useState(false);
useEffect(() => { useEffect(() => {
if (!getResumeToken()) {
setError('Open the private link from your email to continue.');
return undefined;
}
let cancelled = false; let cancelled = false;
api('/api/applications/me') api('/api/applications/me')
@ -31,12 +29,35 @@ export default function SubmittedPage() {
}; };
}, []); }, []);
const decided = application?.status === 'APPROVED' || application?.status === 'REJECTED'; const approved = application?.status === 'APPROVED';
const rejected = application?.status === 'REJECTED';
const pending = application?.status === 'SUBMITTED';
async function openSurvey() {
setSurveyBusy(true);
setError('');
try {
const link = await userApi('/api/survey/me/link', { method: 'POST' });
window.location.assign(link.url);
} catch (linkError) {
setError(linkError.message);
setSurveyBusy(false);
}
}
return ( return (
<main> <section className="agreement-page">
<h1>Agreement submitted</h1> <h1>{approved ? 'Agreement approved' : 'Agreement submitted'}</h1>
<p className="page-lead">Your signed agreement has been sent for review.</p> <p className="page-lead">
{approved
? 'Your agreement is approved. You can continue to the study questionnaire.'
: pending
? 'Your signed agreement is awaiting CREST approval. You will receive an email when it is ready.'
: rejected
? 'This agreement was not approved. Check the reason below or return to the dashboard.'
: 'Your signed agreement has been sent for review.'}
</p>
<FlowSteps current="done" /> <FlowSteps current="done" />
{error ? ( {error ? (
<p className="error" role="alert"> <p className="error" role="alert">
@ -45,8 +66,10 @@ export default function SubmittedPage() {
) : null} ) : null}
<ol className="timeline"> <ol className="timeline">
<li className="done">Submitted</li> <li className="done">Submitted</li>
<li className={decided ? 'done' : 'current'}>Under review</li> <li className={approved || rejected ? 'done' : 'current'}>
<li className={decided ? 'done' : undefined}>Decision emailed</li> {approved ? 'Approved' : rejected ? 'Rejected' : 'Under review'}
</li>
<li className={approved ? 'done' : undefined}>Survey available</li>
</ol> </ol>
{application?.signedPdfUrl ? ( {application?.signedPdfUrl ? (
<p> <p>
@ -55,6 +78,18 @@ export default function SubmittedPage() {
</a> </a>
</p> </p>
) : null} ) : null}
</main> {approved ? (
<div className="actions">
<button type="button" onClick={openSurvey} disabled={surveyBusy}>
{surveyBusy ? 'Opening survey…' : 'Continue to survey'}
</button>
<Link to="/dashboard">Back to dashboard</Link>
</div>
) : (
<p>
<Link to="/dashboard">Back to dashboard</Link>
</p>
)}
</section>
); );
} }

View File

@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useParams } from 'react-router-dom'; import { Link, useParams } from 'react-router-dom';
import { validateAnswers } from '../../../backend/src/survey/validate.js'; import { validateAnswers } from '../../../backend/src/survey/validate.js';
import { surveyApi } from '../surveyApi.js'; import { surveyApi } from '../surveyApi.js';
@ -483,6 +483,11 @@ export default function SurveyPage() {
<main className="wide"> <main className="wide">
<h1>Already submitted</h1> <h1>Already submitted</h1>
<p>{message}</p> <p>{message}</p>
<p className="actions">
<Link to="/dashboard" className="button">
Go to dashboard
</Link>
</p>
</main> </main>
); );
} }
@ -492,6 +497,11 @@ export default function SurveyPage() {
<main className="wide"> <main className="wide">
<h1>Thank you</h1> <h1>Thank you</h1>
<p>Your survey has been submitted.</p> <p>Your survey has been submitted.</p>
<p className="actions">
<Link to="/dashboard" className="button">
Go to dashboard
</Link>
</p>
</main> </main>
); );
} }

View File

@ -0,0 +1,300 @@
import { useEffect, useState } from 'react';
import { Link, useOutletContext } from 'react-router-dom';
import { api } from '../api.js';
import { pathForNextStep, userApi } from '../userApi.js';
/**
* @param {unknown} value
*/
function formatWhen(value) {
if (!value) {
return null;
}
const date = new Date(String(value));
return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString();
}
/**
* @param {string | null | undefined} status
*/
function agreementStatusLabel(status) {
switch (status) {
case 'APPROVED':
return 'Approved';
case 'SUBMITTED':
return 'Submitted — awaiting approval';
case 'REJECTED':
return 'Rejected';
case 'DRAFT':
return 'Draft';
default:
return 'Not started';
}
}
/**
* @param {{ status?: string | null, done?: boolean }} surveyStep
* @param {{ status?: string | null } | null} survey
*/
function surveyStatusLabel(surveyStep, survey) {
if (surveyStep?.done || survey?.status === 'COMPLETED') {
return 'Completed';
}
if (survey?.status === 'IN_PROGRESS' || surveyStep?.status === 'IN_PROGRESS') {
return 'In progress';
}
return 'Not started';
}
/**
* @param {{ status: string }} props
*/
function StatusBadge({ status }) {
const tone = status.toLowerCase();
return <span className={`user-status-badge user-status-${tone}`}>{status}</span>;
}
/**
* @param {{
* application: Record<string, unknown> | null | undefined,
* previewUrl: string,
* finalPdfUrl: string,
* chequeUrl: string,
* previewError: string,
* }} props
*/
function AgreementDetails({ application, previewUrl, finalPdfUrl, chequeUrl, previewError }) {
if (!application) {
return <p className="user-detail-empty">No agreement details yet.</p>;
}
const rows = [
['Agreement date', application.agreement_date],
['Full name', application.full_name],
['Age', application.age],
['Address', application.address],
['Registration no.', application.registration_no],
['Medical council', application.medical_council],
['PAN', application.pan],
['GSTIN', application.gstin || '—'],
['Bank name', application.bank_name],
['Account holder', application.account_holder],
['Account number', application.account_number],
['IFSC', application.ifsc],
];
return (
<div className="user-agreement-layout">
<div className="user-detail-card user-agreement-meta">
<div className="user-agreement-heading">
<h2 id="agreement-details-heading">Agreement details</h2>
{application.status ? <StatusBadge status={String(application.status)} /> : null}
</div>
<dl className="user-detail-grid">
{rows.map(([label, value]) =>
value === undefined || value === null || value === '' ? null : (
<div key={label}>
<dt>{label}</dt>
<dd>{String(value)}</dd>
</div>
),
)}
</dl>
{chequeUrl ? (
<div className="user-cheque-block">
<h3>Cancelled cheque</h3>
<img src={chequeUrl} alt="Cancelled cheque" className="user-cheque-image" />
</div>
) : null}
{finalPdfUrl ? (
<p className="user-detail-links">
<a href={finalPdfUrl} className="button" target="_blank" rel="noopener noreferrer">
Download final agreement PDF
</a>
</p>
) : null}
</div>
<div className="user-detail-card user-agreement-preview">
{previewError ? (
<p className="error" role="alert">
{previewError}
</p>
) : previewUrl ? (
<iframe className="preview-frame" title="Agreement PDF" src={previewUrl} />
) : (
<p className="user-detail-empty">No agreement PDF yet.</p>
)}
</div>
</div>
);
}
/**
* @param {{
* survey: { status?: string | null, startedAt?: unknown, completedAt?: unknown } | null,
* surveyStep: { done?: boolean, status?: string | null },
* }} props
*/
function SurveyDetails({ survey, surveyStep }) {
const status = surveyStatusLabel(surveyStep, survey);
const started = formatWhen(survey?.startedAt);
const completed = formatWhen(survey?.completedAt);
return (
<dl className="user-detail-grid">
<div>
<dt>Status</dt>
<dd>{status}</dd>
</div>
{started ? (
<div>
<dt>Started</dt>
<dd>{started}</dd>
</div>
) : null}
{completed ? (
<div>
<dt>Completed</dt>
<dd>{completed}</dd>
</div>
) : null}
</dl>
);
}
export default function UserDashboardPage() {
const me = useOutletContext();
const [surveyError, setSurveyError] = useState('');
const [surveyBusy, setSurveyBusy] = useState(false);
const [previewUrl, setPreviewUrl] = useState('');
const [finalPdfUrl, setFinalPdfUrl] = useState('');
const [chequeUrl, setChequeUrl] = useState('');
const [previewError, setPreviewError] = useState('');
const [application, setApplication] = useState(me.application);
useEffect(() => {
let cancelled = false;
// Fresh PDF/cheque URLs — layout-cached /api/me URLs expire after a short TTL.
Promise.all([userApi('/api/me'), api('/api/applications/me').catch(() => null)])
.then(([freshMe, appDocs]) => {
if (cancelled) {
return;
}
setApplication(freshMe.application || me.application);
const preview =
appDocs?.pdfUrl ||
appDocs?.finalPdfUrl ||
appDocs?.signedPdfUrl ||
freshMe.application?.finalPdfUrl ||
freshMe.application?.signedPdfUrl ||
'';
setPreviewUrl(preview ? String(preview) : '');
setFinalPdfUrl(
String(appDocs?.finalPdfUrl || freshMe.application?.finalPdfUrl || ''),
);
setChequeUrl(
String(appDocs?.cancelledChequeUrl || freshMe.application?.cancelledChequeUrl || ''),
);
setPreviewError('');
})
.catch((error) => {
if (!cancelled) {
setPreviewError(error.message);
}
});
return () => {
cancelled = true;
};
}, [me.application]);
async function openSurvey() {
setSurveyBusy(true);
setSurveyError('');
try {
const link = await userApi('/api/survey/me/link', { method: 'POST' });
window.location.assign(link.url);
} catch (error) {
setSurveyError(error.message);
setSurveyBusy(false);
}
}
const agreementLabel = me.steps.agreement.done
? 'Completed'
: agreementStatusLabel(application?.status || me.application?.status);
const surveyLabel = surveyStatusLabel(me.steps.survey, me.survey);
return (
<section className="user-dashboard">
<h1>Dashboard</h1>
<p className="page-lead">Signed in as {me.user.email}</p>
<div className="user-step-list">
<div className={`user-step ${me.steps.consent.done ? 'done' : 'pending'}`}>
<strong>1. Consent (PIS / ICF)</strong>
<span>{me.steps.consent.done ? 'Completed' : 'Not started'}</span>
</div>
<div className={`user-step ${me.steps.agreement.done ? 'done' : 'pending'}`}>
<strong>2. Physician agreement</strong>
<span>{agreementLabel}</span>
</div>
<div className={`user-step ${me.steps.survey.done ? 'done' : 'pending'}`}>
<strong>3. Study questionnaire</strong>
<span>{surveyLabel}</span>
</div>
</div>
<section aria-labelledby="agreement-details-heading">
<AgreementDetails
application={application || me.application}
previewUrl={previewUrl}
finalPdfUrl={finalPdfUrl}
chequeUrl={chequeUrl}
previewError={previewError}
/>
</section>
<section className="user-detail-card" aria-labelledby="survey-details-heading">
<h2 id="survey-details-heading">Survey details</h2>
<SurveyDetails survey={me.survey} surveyStep={me.steps.survey} />
</section>
{surveyError ? (
<p className="error" role="alert">
{surveyError}
</p>
) : null}
{me.nextStep === 'consent' || me.nextStep === 'agreement' || me.nextStep === 'survey' ? (
<>
<p className="user-next-hint">
Next step: <strong>{me.nextStep}</strong>
</p>
{me.nextStep === 'survey' ? (
<p>
<button type="button" onClick={openSurvey} disabled={surveyBusy}>
{surveyBusy ? 'Opening survey…' : 'Continue to survey'}
</button>
</p>
) : (
<p>
<Link to={pathForNextStep(me.nextStep)}>Continue to {me.nextStep}</Link>
</p>
)}
</>
) : (
<p className="banner" role="status">
All steps are complete. Thank you for participating.
</p>
)}
</section>
);
}

View File

@ -0,0 +1,195 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import SiteHeader from '../SiteHeader.jsx';
import { pathForNextStep, userApi } from '../userApi.js';
const RESEND_SECONDS = 60;
export default function UserLoginPage() {
const navigate = useNavigate();
const [email, setEmail] = useState('');
const [otp, setOtp] = useState('');
const [step, setStep] = useState('email');
const [message, setMessage] = useState('');
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const [resendIn, setResendIn] = useState(0);
useEffect(() => {
let cancelled = false;
userApi('/api/me', { redirectOn401: false })
.then((me) => {
if (!cancelled) {
navigate(pathForNextStep(me.nextStep), { replace: true });
}
})
.catch(() => {});
return () => {
cancelled = true;
};
}, [navigate]);
useEffect(() => {
if (resendIn <= 0) {
return undefined;
}
const timer = window.setTimeout(() => setResendIn((value) => value - 1), 1000);
return () => window.clearTimeout(timer);
}, [resendIn]);
async function requestCode(event) {
event.preventDefault();
setError('');
setMessage('');
setBusy(true);
try {
const result = await userApi('/api/auth/otp/request', {
method: 'POST',
body: { email },
redirectOn401: false,
});
setMessage(result.message || 'A one-time code has been sent.');
setStep('otp');
setResendIn(RESEND_SECONDS);
} catch (requestError) {
setError(requestError.message);
} finally {
setBusy(false);
}
}
async function verifyCode(event) {
event.preventDefault();
setError('');
setBusy(true);
try {
const result = await userApi('/api/auth/otp/verify', {
method: 'POST',
body: { email, otp },
redirectOn401: false,
});
navigate(pathForNextStep(result.me?.nextStep), { replace: true });
} catch (verifyError) {
setError(verifyError.message);
setBusy(false);
}
}
async function resendCode() {
if (resendIn > 0 || busy) {
return;
}
setError('');
setBusy(true);
try {
const result = await userApi('/api/auth/otp/request', {
method: 'POST',
body: { email },
redirectOn401: false,
});
setMessage(result.message || 'A new code has been sent.');
setResendIn(RESEND_SECONDS);
} catch (requestError) {
setError(requestError.message);
} finally {
setBusy(false);
}
}
return (
<div className="admin-login">
<SiteHeader />
<div className="admin-login-body">
<div className="admin-card admin-login-card">
<h1 className="admin-title">Sign in</h1>
<p className="page-lead" style={{ textAlign: 'center', marginBottom: '1rem' }}>
Enter your email to receive a one-time code.
</p>
{step === 'email' ? (
<form className="grid gap-3" onSubmit={requestCode}>
<label className="grid gap-1 text-sm font-medium text-stone-600" htmlFor="user-email">
Email
<input
id="user-email"
type="email"
autoComplete="username"
required
className="admin-input"
value={email}
onChange={(event) => setEmail(event.target.value)}
/>
</label>
{error ? (
<p className="text-sm text-red-700" role="alert">
{error}
</p>
) : null}
<button type="submit" className="admin-btn admin-btn-primary" disabled={busy}>
{busy ? 'Sending…' : 'Send code'}
</button>
</form>
) : (
<form className="grid gap-3" onSubmit={verifyCode}>
<p className="text-sm text-stone-600">
Code sent to <strong>{email}</strong>
</p>
<label className="grid gap-1 text-sm font-medium text-stone-600" htmlFor="user-otp">
One-time code
<input
id="user-otp"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
pattern="[0-9]{6}"
maxLength={6}
required
className="admin-input"
value={otp}
onChange={(event) => setOtp(event.target.value.replace(/\D/g, '').slice(0, 6))}
/>
</label>
{message ? <p className="text-sm text-stone-600">{message}</p> : null}
{error ? (
<p className="text-sm text-red-700" role="alert">
{error}
</p>
) : null}
<button type="submit" className="admin-btn admin-btn-primary" disabled={busy}>
{busy ? 'Verifying…' : 'Verify and continue'}
</button>
<button
type="button"
className="admin-btn admin-btn-secondary"
disabled={busy || resendIn > 0}
onClick={resendCode}
>
{resendIn > 0 ? `Resend code in ${resendIn}s` : 'Resend code'}
</button>
<button
type="button"
className="admin-btn admin-btn-ghost"
disabled={busy}
onClick={() => {
setStep('email');
setOtp('');
setError('');
setMessage('');
}}
>
Use a different email
</button>
</form>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,85 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { afterEach, describe, expect, it, vi } from 'vitest';
import UserLoginPage from './UserLoginPage.jsx';
describe('UserLoginPage', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('requests an OTP and then verifies it', async () => {
const fetchMock = vi.fn(async (path, options = {}) => {
if (path === '/api/me') {
return {
ok: false,
status: 401,
json: async () => ({ error: { message: 'Authentication required' } }),
};
}
if (path === '/api/auth/otp/request' && options.method === 'POST') {
return {
ok: true,
status: 200,
json: async () => ({
ok: true,
message: 'If that email can sign in, a one-time code has been sent.',
}),
};
}
if (path === '/api/auth/otp/verify' && options.method === 'POST') {
return {
ok: true,
status: 200,
json: async () => ({
user: { id: 1, email: 'doctor@example.com', name: null },
me: {
user: { id: 1, email: 'doctor@example.com', name: null },
nextStep: 'consent',
steps: {
consent: { done: false },
agreement: { done: false },
survey: { done: false, status: null },
},
},
}),
};
}
return { ok: false, status: 404, json: async () => ({}) };
});
vi.stubGlobal('fetch', fetchMock);
render(
<MemoryRouter>
<UserLoginPage />
</MemoryRouter>,
);
fireEvent.change(await screen.findByLabelText('Email'), {
target: { value: 'doctor@example.com' },
});
fireEvent.click(screen.getByRole('button', { name: 'Send code' }));
expect(await screen.findByLabelText('One-time code')).toBeInTheDocument();
expect(screen.getByText(/doctor@example.com/)).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('One-time code'), {
target: { value: '123456' },
});
fireEvent.click(screen.getByRole('button', { name: 'Verify and continue' }));
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith(
'/api/auth/otp/verify',
expect.objectContaining({
method: 'POST',
credentials: 'include',
}),
);
});
});
});

View File

@ -0,0 +1,112 @@
import { useEffect, useRef, useState } from 'react';
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
import logo from '../images/CREST_logo.jpg';
import { userApi } from '../userApi.js';
export default function UserLayout() {
const navigate = useNavigate();
const location = useLocation();
const [me, setMe] = useState(null);
const [menuOpen, setMenuOpen] = useState(false);
const menuRef = useRef(null);
const wideMain = location.pathname === '/dashboard';
useEffect(() => {
let cancelled = false;
userApi('/api/me')
.then((payload) => {
if (!cancelled) {
setMe(payload);
}
})
.catch(() => {
if (!cancelled) {
navigate('/login');
}
});
return () => {
cancelled = true;
};
}, [navigate]);
useEffect(() => {
if (!menuOpen) {
return undefined;
}
function onPointerDown(event) {
if (menuRef.current && !menuRef.current.contains(event.target)) {
setMenuOpen(false);
}
}
document.addEventListener('pointerdown', onPointerDown);
return () => document.removeEventListener('pointerdown', onPointerDown);
}, [menuOpen]);
async function logout() {
setMenuOpen(false);
await userApi('/api/auth/logout', { method: 'POST', redirectOn401: false });
navigate('/login');
}
if (!me) {
return <p className="user-shell-loading">Checking your session…</p>;
}
const accountLabel = me.user.name || me.user.email;
return (
<div className="user-shell site">
<header className="user-topbar">
<div className="user-topbar-inner">
<Link to="/dashboard" className="user-brand">
<img src={logo} alt="CREST" />
<span>Adherence Pack Study</span>
</Link>
<div className="user-menu" ref={menuRef}>
<button
type="button"
className="user-menu-trigger"
aria-expanded={menuOpen}
aria-haspopup="menu"
aria-label={`Account menu for ${accountLabel}`}
title={accountLabel}
onClick={() => setMenuOpen((open) => !open)}
>
<svg
className="user-menu-icon"
aria-hidden="true"
viewBox="0 0 24 24"
width="20"
height="20"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="8" r="3.25" />
<path d="M5.5 19.25c.9-3.1 3.2-4.75 6.5-4.75s5.6 1.65 6.5 4.75" />
</svg>
<span aria-hidden="true">▾</span>
</button>
{menuOpen ? (
<div className="user-menu-panel" role="menu">
<p className="user-menu-email">{me.user.email}</p>
<button type="button" role="menuitem" onClick={logout}>
Log out
</button>
</div>
) : null}
</div>
</div>
</header>
<main className={wideMain ? 'user-main wide' : 'user-main'}>
<Outlet context={me} />
</main>
</div>
);
}

55
frontend/src/userApi.js Normal file
View File

@ -0,0 +1,55 @@
/**
* Physician requests use the user_token cookie.
* @param {string} path
* @param {{ method?: string, body?: Record<string, unknown>, redirectOn401?: boolean }} [options]
*/
export async function userApi(path, options = {}) {
const headers = { Accept: 'application/json' };
if (options.body) {
headers['Content-Type'] = 'application/json';
}
const response = await fetch(path, {
method: options.method ?? 'GET',
headers,
credentials: 'include',
body: options.body ? JSON.stringify(options.body) : undefined,
});
if (response.status === 401 && options.redirectOn401 !== false) {
if (window.location.pathname !== '/login') {
window.location.assign('/login');
}
}
if (response.status === 204) {
return null;
}
const data = await response.json().catch(() => ({}));
if (!response.ok) {
const error = new Error(data?.error?.message || 'Request failed');
error.status = response.status;
throw error;
}
return data;
}
/**
* @param {'consent' | 'agreement' | 'survey' | 'dashboard' | string | undefined} nextStep
*/
export function pathForNextStep(nextStep) {
switch (nextStep) {
case 'agreement':
return '/agreement';
case 'survey':
case 'dashboard':
return '/dashboard';
case 'consent':
default:
return '/consent';
}
}

View File

@ -5,6 +5,7 @@ import react from '@vitejs/plugin-react';
import { defineConfig } from 'vitest/config'; import { defineConfig } from 'vitest/config';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const apiProxyTarget = process.env.API_PROXY_TARGET || 'http://localhost:4000';
export default defineConfig({ export default defineConfig({
plugins: [react(), tailwindcss()], plugins: [react(), tailwindcss()],
@ -14,7 +15,11 @@ export default defineConfig({
allow: [root], allow: [root],
}, },
proxy: { proxy: {
'/api': 'http://localhost:4000', '/api': {
target: apiProxyTarget,
changeOrigin: true,
secure: apiProxyTarget.startsWith('https'),
},
}, },
}, },
test: { test: {

Binary file not shown.

View File

@ -0,0 +1,139 @@
# Consultation Agreement — Extended-Dose Pack Study (MOU, Final)
Executed on this ___ day of ________ 2026 at Pune, Maharashtra.
## Parties
**Party of the First Part:** Chest Research And Training Private Limited ("CREST"), a company incorporated under the Companies Act, 2013, registered office at Pune, India (includes successors and permitted assigns).
**Party of the Second Part:** Dr. ______________________, aged ___ years, residing at ______________________, Registration No. ______________________, registered with the ______________ Medical Council, PAN No. ______________, GSTIN (if applicable) ______________ (the "Consultant" or "Physician"; includes legal representatives, heirs, executors, administrators, and permitted assigns).
CREST and the Physician are individually a "Party" and collectively the "Parties".
## Recitals
- 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"** to collect professional medical opinions from practicing physicians.
- The 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 promotion, endorsement, or prescription of any pharmaceutical product.
- The Consultant is a duly qualified and registered medical practitioner with the requisite qualifications, experience, and expertise.
- The Consultant has voluntarily agreed to participate and provide independent professional opinions per this Agreement.
## 1. Purpose
1.1 CREST appoints the Consultant on a non-exclusive basis to participate in the physician opinion survey.
1.2 The Consultant accepts and agrees to provide independent professional opinions strictly in accordance with this Agreement.
## 2. Scope of Services
The Consultant shall:
a. Participate in the physician opinion survey.
b. Complete the online questionnaire of approximately 15–20 questions.
c. Respond based solely on independent professional knowledge, experience, and clinical judgment.
d. Provide truthful, complete, and unbiased responses.
e. Submit the completed questionnaire within the timeline communicated by CREST.
## 3. Obligations of the First Party
1. Provide the Second Party with the survey questionnaire and necessary instructions.
2. Conduct the survey in accordance with applicable laws and ethical standards.
3. Pay the agreed honorarium, along with applicable GST (if any), subject to statutory deductions.
4. Provide reasonable support for completion of the survey.
## 3A. Obligations of the Second Party
1. Complete the survey honestly, independently, and within the prescribed timeline.
2. Maintain confidentiality of all study-related information.
3. Comply with applicable laws and professional ethics, and obtain necessary approvals if required.
4. Disclose any conflict of interest that may affect participation.
5. Acknowledge that the honorarium is solely for professional consultation and not for promoting or prescribing any product. The Consultant shall provide independent professional opinion.
6. The Consultant is under no obligation to provide favourable opinions regarding any pharmaceutical product.
## 4. Term and Termination
4.1 Commences on the Effective Date and remains valid until completion of the survey and payment of the honorarium, unless terminated earlier.
4.2 Either Party may terminate by written notice if:
a. the other Party commits a material breach and fails to cure it within fifteen (15) days;
b. continuation becomes unlawful;
c. the study is withdrawn or cancelled.
Where the Consultant has substantially completed the agreed services before termination, CREST may, at its discretion, pay the honorarium on a proportionate basis.
## 5. Consideration
5.1 Subject to satisfactory completion of the survey, CREST shall pay a professional honorarium of **Rs. 15,000/- (Rupees Fifteen Thousand only)**.
5.2 Payment shall be made within 15 days from completion of the survey and receipt of all necessary documents.
## 6. Consultant's Representations
The Consultant represents and warrants that:
6.1 He/she is duly qualified and registered with the appropriate Medical Council.
6.2 Participation is voluntary.
6.3 Responses shall represent his/her own independent views.
6.4 Participation does not violate any employment contract, hospital policy, or applicable law.
6.5 Necessary approvals from his/her employer, if required, have been obtained.
## 7. Confidentiality
7.1 The Consultant shall maintain strict confidentiality regarding: a. Study protocol; b. Questionnaire; c. Study documents; d. Data received; e. Findings; f. Sponsor identity, if designated confidential; g. Any proprietary information shared by CREST.
7.2 The Consultant shall not disclose confidential information without prior written consent, except where required by law.
## 8. Data Privacy
8.1 The Consultant consents to CREST collecting, storing, processing, and using personal information solely for conducting the study, making payments, statutory compliance, audit, and publication purposes.
8.2 CREST shall process personal data in accordance with applicable Indian data protection laws.
8.3 Survey responses may be anonymized and aggregated before analysis or publication.
## 9. Intellectual Property
9.1 All questionnaires, study protocols, databases, reports, publications, statistical analyses, and study documents remain the exclusive property of CREST and/or the study sponsor.
9.2 The Consultant shall not claim ownership over the study materials.
9.3 The Consultant shall not publish or distribute the questionnaire or study results without prior written approval from CREST.
## 10. Conflict of Interest
The Consultant shall disclose any actual or potential conflict of interest that may affect participation in the study.
## 11. No Promotional Activity
The Consultant acknowledges that:
a. The engagement is purely for obtaining scientific and professional opinions.
b. The Consultant is not engaged to market, advertise, promote, prescribe, recommend, or endorse any pharmaceutical product.
c. Participation shall not influence the Consultant's clinical judgment or prescribing practices.
## 12. Indemnity
12.1 The Consultant shall indemnify CREST against losses arising from: a. breach of this Agreement; b. fraudulent or misleading information furnished by the Consultant; c. violation of applicable laws by the Consultant.
12.2 CREST shall not be responsible for any independent professional opinion expressed by the Consultant.
## 13. Force Majeure
Neither Party is liable for delay or failure in performance resulting from events beyond reasonable control, including acts of God, pandemics, governmental restrictions, strikes, war, cyberattacks, or natural disasters.
## 14. Dispute Resolution
14.1 The Parties shall first try to resolve disputes through good-faith discussions.
14.2 If unresolved within thirty (30) days, the dispute is referred to arbitration under the Arbitration and Conciliation Act, 1996.
14.3 Arbitration is by a sole arbitrator appointed by the Party of the First Part. Seat and venue: Pune, Maharashtra. Language: English.
## 15. Miscellaneous
15.1 No amendment is valid unless in writing and signed by authorised representatives of both Parties.
15.2 Neither Party may assign its rights or obligations without the other's prior written consent.
15.3 Nothing herein creates a partnership, agency, employment, or service-provider relationship.
15.4 This Agreement is the entire understanding between the Parties on its subject matter and supersedes all prior discussions, negotiations, and correspondence.
## Signatures (IN WITNESS WHEREOF)
**For CREST Private Limited**
Authorized Signatory — Name: ______ | Designation: ______ | Signature: ______ | Date: ______
**Consultant / Physician**
Dr. ______ | Medical Registration No.: ______ | PAN: ______ | GSTIN (if applicable): ______
Bank Details — Bank Name: ______ | Account Holder: ______ | Account Number: ______ | IFSC: ______
Signature: ______ | Date: ______

116
help/study_questionnaire.md Normal file
View File

@ -0,0 +1,116 @@
# Study Questionnaire — Survey Questionnaire Version 01 (Date: 14/10/2026)
**CREST Pvt. Ltd. — Confidential**
**Study 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
---
## Section 1: Basic Demographics / Doctor Details
- **Name:** ______
- **Qualification** (single choice): MBBS / MD (Chest) / DM (Pulmo / DNB / DCH) / MD (Med) / DNB (Med) / Any other (specify): ______
- **Years of Practice:** ______
- **Practice setting** (single choice): Private Clinic / Private Hospital / Govt. Hospital
- **Specialization** (single choice): Chest Physician / Consulting Physician / Other (specify): ______
- **City:** ______
- **State:** ______
## Section 2: Physician Practice Profile
1. On an average, how many patients do you see per month? ______
2. Approximately how many **asthma** patients do you manage per month?
- New patients: ___
- Follow-up/old patients: ___
3. Approximately how many **COPD** patients do you manage per month?
- New patients: ___
- Follow-up/old patients: ___
## Section 3: Foracort Prescribing Pattern
4. Approximately what proportion of your **asthma** patients receive Formoterol/Budesonide? ___ %
5. Approximately what proportion of your **COPD** patients receive Formoterol/Budesonide? ___ %
6. For how long have you been prescribing the Extended-Dose Pack to your patients with asthma and/or COPD? (single choice)
- [ ] Less than 6 months
- [ ] 6 months to less than 1 year
- [ ] More than 1 year
7. Among your patients receiving Foracort, approximately what proportion do you prescribe (each column must total 100%):
| Pack type | Product | Pack info | Asthma | COPD |
|---|---|---|---|---|
| Regular Pack | Formoterol/Budesonide 200 & 400 mcg pMDI | 120 doses (unit dose cost same for 120 and 180 dose packs) | ___% | ___% |
| Regular Pack | Rotacap | Pack of 30 (unit dose cost same for 30 and 60 dose pack) | ___% | ___% |
| Extended-Dose Pack | Formoterol/Budesonide 200 & 400 mcg pMDI | 180 doses (unit dose cost same for 120 and 180 dose packs) | ___% | ___% |
| Extended-Dose Pack | Rotacap | Pack of 60 (unit dose cost same for 30 and 60 dose pack) | ___% | ___% |
| **Total** | | | **100%** | **100%** |
## Section 4: Physician Assessment of Adherence
8. Based on your clinical experience, approximately what proportion of patients are adherent to their prescribed treatment? (Estimate out of 100 patients.)
*Note: 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.*
| Pack type | Product | Asthma | COPD |
|---|---|---|---|
| Regular Pack | Formoterol/Budesonide 200 & 400 mcg pMDI (120 doses) | ___ / 100 | ___ / 100 |
| Regular Pack | Rotacap (pack of 30) | ___ / 100 | ___ / 100 |
| Extended-Dose Pack | Formoterol/Budesonide 200 & 400 mcg pMDI (180 doses) | ___ / 100 | ___ / 100 |
| Extended-Dose Pack | Rotacap (pack of 60) | ___ / 100 | ___ / 100 |
9. Compared with patients receiving the regular pack, adherence among patients receiving the extended-dose pack is: (single choice)
- [ ] Much better
- [ ] Somewhat better
- [ ] No difference
- [ ] Somewhat worse
- [ ] Much worse
- [ ] Unable to assess
## Section 5: Reasons for Non-adherence
10. In your opinion, what are the key barriers to adherence to inhaled maintenance therapy of formoterol/budesonide in your asthma and COPD patients? (multi-select)
- [ ] Side effects
- [ ] Lack of understanding of the need for regular treatment
- [ ] Delay in refill due to poor accessibility
- [ ] Perception that medication is not required when symptoms improve
- [ ] Fear/concerns about long-term medication
- [ ] Cost
- [ ] Difficulty remembering to take doses
- [ ] Inhaler technique difficulties
- [ ] Patient lifestyle/work-related factors
- [ ] Other (specify): ______
## Section 6: Patient Characteristics Associated with Adherence
11. Which patients, in your opinion, are most likely to benefit from an Extended-dose pack? (multi-select)
- [ ] Elderly patients
- [ ] Difficulty accessing the pharmacy for a refill (e.g., remote location)
- [ ] Patients with frequent exacerbations
- [ ] Patients frequently forgetting doses
- [ ] Patients taking multiple medications
- [ ] Patients with poor disease awareness
- [ ] All patients requiring long-term maintenance therapy
- [ ] Busy lifestyle / working professional
- [ ] Frequent travellers
- [ ] Caregiver-dependent patients
- [ ] Other: ______
## Section 7: Physician Perspective on the Extended-Dose Pack
12. What do you consider the main advantages / disadvantages of the Extended-Dose Pack compared to the Regular Pack?
- Advantages: ______
- Disadvantages: ______
13. Overall, how would you rate the Extended-Dose Pack compared with the Regular Pack with respect to the following? (Likert scale per row: Strongly disagree / Disagree / Neither agree nor disagree / Agree / Strongly agree)
1. Using an adherence pack is more convenient for patients
2. Using an adherence pack reduces the frequency of medication refills
3. Using an adherence pack improves medication adherence
4. Using an adherence pack helps in maintaining treatment continuity
5. Using an adherence pack improves patient satisfaction
6. Using an adherence pack helps achieve better disease control
7. Using an adherence pack is more cost-beneficial for patients because it reduces the frequency of doctor visits
8. Using an adherence pack may be detrimental because it can delay follow-up visits with the doctor
9. Patients are reluctant to bear the higher initial purchase amount
10. Higher chance of damage to Rotacaps due to humidity in the extended-dose pack
> Note: In the source PDF, rows 9 and 10 appear without the rating options (the page appears cut off); they are presumably meant to use the same 5-point scale.

66
package-lock.json generated
View File

@ -2279,9 +2279,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -2452,9 +2449,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -2469,9 +2463,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -2486,9 +2477,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -2503,9 +2491,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -2520,9 +2505,6 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -2537,9 +2519,6 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -2554,9 +2533,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -2571,9 +2547,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -2588,9 +2561,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -2605,9 +2575,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -2622,9 +2589,6 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -2639,9 +2603,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -2656,9 +2617,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -3025,9 +2983,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -3045,9 +3000,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -3065,9 +3017,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -3085,9 +3034,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -7804,9 +7750,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -7828,9 +7771,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -7852,9 +7792,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -7876,9 +7813,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [

View File

@ -11,6 +11,7 @@
], ],
"scripts": { "scripts": {
"dev": "concurrently -n backend,frontend -c blue,green \"npm run dev -w backend\" \"npm run dev -w frontend\"", "dev": "concurrently -n backend,frontend -c blue,green \"npm run dev -w backend\" \"npm run dev -w frontend\"",
"dev:frontend": "npm run dev:remote -w frontend",
"lint": "eslint . && prettier --check .", "lint": "eslint . && prettier --check .",
"migrate": "npm run migrate -w backend", "migrate": "npm run migrate -w backend",
"seed:admin": "npm run seed:admin -w backend", "seed:admin": "npm run seed:admin -w backend",