#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:
parent
5246560f1f
commit
bb3f8b37a0
11
README.md
11
README.md
|
|
@ -34,6 +34,17 @@ npm run dev
|
|||
|
||||
The Vite dev server proxies `/api` to port 4000.
|
||||
|
||||
### Frontend only
|
||||
|
||||
If you are not running the local API, proxy `/api` to the hosted backend:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev:frontend
|
||||
```
|
||||
|
||||
Then open http://localhost:5173. Requests to `/api` go to `https://pppaimt-crest.xltr.in`. Do not submit live agreements unless you intend to change production data.
|
||||
|
||||
## Database
|
||||
|
||||
Create the application database, then:
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
|
|
@ -74,6 +74,7 @@ export async function createServer() {
|
|||
'req.headers.authorization',
|
||||
'req.headers.cookie',
|
||||
'req.payload.password',
|
||||
'req.payload.otp',
|
||||
'req.payload.pan',
|
||||
'req.payload.account_number',
|
||||
'req.params.token',
|
||||
|
|
@ -127,6 +128,19 @@ export async function createServer() {
|
|||
},
|
||||
});
|
||||
server.auth.default({ strategy: 'admin', mode: 'try' });
|
||||
|
||||
// Physician session cookie (separate from @hapi/cookie admin strategy).
|
||||
server.state('user_token', {
|
||||
ttl: 8 * 60 * 60 * 1000,
|
||||
isSecure: env.NODE_ENV === 'production',
|
||||
isHttpOnly: true,
|
||||
isSameSite: 'Lax',
|
||||
path: '/',
|
||||
encoding: 'iron',
|
||||
password: env.JWT_SECRET,
|
||||
clearInvalid: true,
|
||||
ignoreErrors: true,
|
||||
});
|
||||
}
|
||||
|
||||
await registerRoutes(server);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { db } from './knex.js';
|
||||
import { seedSurveyDefinition } from '../survey/seed.js';
|
||||
import { SURVEY_DEFINITION_VERSION, seedSurveyDefinition } from '../survey/seed.js';
|
||||
|
||||
const id = await seedSurveyDefinition();
|
||||
console.log(`Survey definition version 1 is ready (${id})`);
|
||||
console.log(`Survey definition version ${SURVEY_DEFINITION_VERSION} is ready (${id})`);
|
||||
await db.destroy();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -2,13 +2,20 @@ import { readFileSync } from 'node:fs';
|
|||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const AGREEMENT_PATH = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'../../../files/docs/agreement.md',
|
||||
);
|
||||
const ROOT = path.dirname(fileURLToPath(import.meta.url));
|
||||
const AGREEMENT_PATH = path.resolve(ROOT, '../../../files/docs/agreement.md');
|
||||
const LOGO_PATH = path.resolve(ROOT, '../../../frontend/src/images/CREST_logo.jpg');
|
||||
|
||||
const sourceText = readFileSync(AGREEMENT_PATH, 'utf8');
|
||||
|
||||
/** @type {string} */
|
||||
let crestLogoDataUrl = '';
|
||||
try {
|
||||
crestLogoDataUrl = `data:image/jpeg;base64,${readFileSync(LOGO_PATH).toString('base64')}`;
|
||||
} catch {
|
||||
crestLogoDataUrl = '';
|
||||
}
|
||||
|
||||
const MONTHS = [
|
||||
'January',
|
||||
'February',
|
||||
|
|
@ -80,6 +87,18 @@ function physicianSignatureHtml(signature) {
|
|||
return '___________________';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} signatureMarkup
|
||||
* @param {{ withLogo?: boolean }} [options]
|
||||
*/
|
||||
function signatureRow(signatureMarkup, options = {}) {
|
||||
const logo =
|
||||
options.withLogo && crestLogoDataUrl
|
||||
? `<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 {string} dateLabel
|
||||
|
|
@ -91,12 +110,12 @@ function crestBlock(signatory, dateLabel) {
|
|||
|
||||
const image = signatory.signatureDataUrl
|
||||
? `<img class="signature" alt="CREST signature" src="${signatory.signatureDataUrl}" />`
|
||||
: '';
|
||||
: '___________________';
|
||||
|
||||
return [
|
||||
`Name: ${filled(signatory.name)}`,
|
||||
`Designation: ${filled(signatory.designation)}`,
|
||||
`Signature: ${image}`,
|
||||
signatureRow(image, { withLogo: true }),
|
||||
`Date: ${filled(dateLabel)}`,
|
||||
].join('\n\n');
|
||||
}
|
||||
|
|
@ -287,7 +306,10 @@ export function agreementHtml(options) {
|
|||
.filled { font-weight: 700; text-decoration: underline; text-underline-offset: 0.15em; }
|
||||
.watermark { position: fixed; top: 45%; left: -10%; width: 120%; text-align: center; font-size: 42pt; font-weight: 700; letter-spacing: 0.04em; color: rgba(185, 28, 28, 0.18); transform: rotate(-32deg); z-index: 10; }
|
||||
.typed-signature { font-family: "Segoe Script", "Apple Chancery", cursive; font-size: 20pt; }
|
||||
img.signature { height: 64px; }
|
||||
.signature-row { display: flex; justify-content: space-between; align-items: center; gap: 1rem; width: 100%; }
|
||||
.signature-field { display: inline-flex; align-items: center; gap: 0.5rem; min-height: 64px; }
|
||||
img.signature { height: 64px; max-width: 240px; width: auto; vertical-align: middle; background: #fff; }
|
||||
img.crest-logo { height: 52px; width: auto; flex: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
|
|
@ -37,6 +37,44 @@ function binary() {
|
|||
return env.WEASYPRINT_BIN || 'weasyprint';
|
||||
}
|
||||
|
||||
/**
|
||||
* Write data-URI images next to the HTML so WeasyPrint loads them as local files.
|
||||
* @param {string} html
|
||||
* @param {string} directory
|
||||
* @returns {Promise<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} pdfPath
|
||||
|
|
@ -46,6 +84,7 @@ function runWeasyPrint(htmlPath, pdfPath) {
|
|||
let settled = false;
|
||||
const child = spawn(binary(), [htmlPath, pdfPath], {
|
||||
stdio: ['ignore', 'ignore', 'pipe'],
|
||||
cwd: path.dirname(htmlPath),
|
||||
});
|
||||
let stderr = '';
|
||||
|
||||
|
|
@ -96,7 +135,8 @@ export async function renderPdf(html) {
|
|||
try {
|
||||
const htmlPath = path.join(directory, 'agreement.html');
|
||||
const pdfPath = path.join(directory, 'agreement.pdf');
|
||||
await writeFile(htmlPath, html, 'utf8');
|
||||
const prepared = await materializeDataUriImages(html, directory);
|
||||
await writeFile(htmlPath, prepared, 'utf8');
|
||||
await runWeasyPrint(htmlPath, pdfPath);
|
||||
return await readFile(pdfPath);
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -57,13 +57,21 @@ export const applicationRoutes = {
|
|||
server.route({
|
||||
method: 'PUT',
|
||||
path: '/api/applications/me',
|
||||
options: { plugins: rateLimit },
|
||||
options: {
|
||||
plugins: rateLimit,
|
||||
payload: {
|
||||
// Cancelled-cheque images are base64 data URLs (5MB file ≈ ~6.7MB encoded).
|
||||
maxBytes: 8 * 1024 * 1024,
|
||||
},
|
||||
},
|
||||
async handler(request) {
|
||||
const application = await applicationFromRequest(request);
|
||||
const payload =
|
||||
request.payload && typeof request.payload === 'object' ? request.payload : {};
|
||||
const finalize = request.query.finalize === 'true' || request.query.finalize === true;
|
||||
return saveApplication(request, application, payload, finalize);
|
||||
const saved = await saveApplication(request, application, payload, finalize);
|
||||
// Same shape as GET so cancelledChequeUrl (and PDF urls) are available immediately.
|
||||
return presentWithPdf(saved);
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -2,9 +2,11 @@ import { env } from '../config/env.js';
|
|||
import { adminRoutes } from './admin.js';
|
||||
import { adminAuthRoutes } from './adminAuth.js';
|
||||
import { applicationRoutes } from './applications.js';
|
||||
import { consentRoutes } from './consent.js';
|
||||
import { devFileRoutes } from './devFiles.js';
|
||||
import { healthRoutes } from './health.js';
|
||||
import { surveyRoutes } from './survey.js';
|
||||
import { userAuthRoutes } from './userAuth.js';
|
||||
import { webhookRoutes } from './webhooks.js';
|
||||
|
||||
/**
|
||||
|
|
@ -13,6 +15,8 @@ import { webhookRoutes } from './webhooks.js';
|
|||
export async function registerRoutes(server) {
|
||||
await server.register(healthRoutes);
|
||||
await server.register(adminAuthRoutes);
|
||||
await server.register(userAuthRoutes);
|
||||
await server.register(consentRoutes);
|
||||
await server.register(adminRoutes);
|
||||
await server.register(applicationRoutes);
|
||||
await server.register(surveyRoutes);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
import Boom from '@hapi/boom';
|
||||
import { z } from 'zod';
|
||||
import { getSurvey, saveSurveyProgress, submitSurvey } from '../services/survey.js';
|
||||
import { requireUser } from '../middleware/requireUser.js';
|
||||
import {
|
||||
getSurvey,
|
||||
issueSurveyLinkForUser,
|
||||
saveSurveyProgress,
|
||||
submitSurvey,
|
||||
} from '../services/survey.js';
|
||||
|
||||
const answersSchema = z.object({
|
||||
answers: z.record(z.string().max(80), z.unknown()),
|
||||
|
|
@ -36,6 +42,19 @@ function answersFrom(payload) {
|
|||
export const surveyRoutes = {
|
||||
name: 'survey-routes',
|
||||
register(server) {
|
||||
server.route({
|
||||
method: 'POST',
|
||||
path: '/api/survey/me/link',
|
||||
options: {
|
||||
auth: false,
|
||||
pre: [requireUser],
|
||||
plugins: submitLimit,
|
||||
},
|
||||
handler(request) {
|
||||
return issueSurveyLinkForUser(Number(request.pre.user.id));
|
||||
},
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: 'GET',
|
||||
path: '/api/survey/{token}',
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -59,6 +59,8 @@ export const draftApplicationSchema = z
|
|||
account_number: optionalText(text(z.string().regex(accountPattern))),
|
||||
ifsc: optionalText(text(z.string().regex(ifscPattern))),
|
||||
accepted: z.boolean().optional(),
|
||||
/** Optional cancelled-cheque image as a data URL, or null to remove. */
|
||||
cancelled_cheque: z.union([z.string().min(1), z.null()]).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
});
|
||||
|
|
@ -1,12 +1,10 @@
|
|||
import { createHash } from 'node:crypto';
|
||||
import Boom from '@hapi/boom';
|
||||
import argon2 from 'argon2';
|
||||
import { z } from 'zod';
|
||||
import { db } from '../db/knex.js';
|
||||
import { auditFromRequest } from '../middleware/audit.js';
|
||||
import { agreementHtml } from '../pdf/agreementHtml.js';
|
||||
import { renderPdf } from '../pdf/render.js';
|
||||
import { recordAudit } from './audit.js';
|
||||
import { finalizeSubmittedApplication } from './applicationApproval.js';
|
||||
import { presentApplication } from './applications.js';
|
||||
import { maskSecret, panLookup } from './fieldCrypto.js';
|
||||
import { readPngDataUrl } from './png.js';
|
||||
|
|
@ -18,7 +16,6 @@ import { generateToken, hashToken } from './tokens.js';
|
|||
const SIGNATURE_KEY = 'settings/signatory-signature.png';
|
||||
const SIGNATURE_LIMIT = 500 * 1024;
|
||||
const STATUSES = ['DRAFT', 'SUBMITTED', 'APPROVED', 'REJECTED'];
|
||||
const MISSING_SIGNATURE = 'Upload the CREST signatory signature in Settings before approving.';
|
||||
|
||||
const blankToUndefined = (value) => (value === '' ? undefined : value);
|
||||
|
||||
|
|
@ -119,13 +116,6 @@ function startOfWeek() {
|
|||
return date;
|
||||
}
|
||||
|
||||
function todayIso() {
|
||||
const now = new Date();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(now.getDate()).padStart(2, '0');
|
||||
return `${now.getFullYear()}-${month}-${day}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} image
|
||||
*/
|
||||
|
|
@ -323,31 +313,6 @@ export async function getApplication(id) {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<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 {number} id
|
||||
|
|
@ -355,94 +320,11 @@ async function physicianSignature(application) {
|
|||
export async function approveApplication(request, id) {
|
||||
const application = await applicationById(id);
|
||||
|
||||
if (application.status !== 'SUBMITTED') {
|
||||
throw Boom.conflict('Only a submitted application can be approved');
|
||||
}
|
||||
|
||||
const signatory = await getSignatory();
|
||||
|
||||
if (!signatory.signatureKey) {
|
||||
throw Boom.badRequest(MISSING_SIGNATURE);
|
||||
}
|
||||
|
||||
let signatureBytes;
|
||||
|
||||
try {
|
||||
signatureBytes = await getObject(signatory.signatureKey);
|
||||
} catch {
|
||||
throw Boom.badRequest(MISSING_SIGNATURE);
|
||||
}
|
||||
|
||||
const presented = presentApplication(application);
|
||||
presented.status = 'APPROVED';
|
||||
const pdf = await renderPdf(
|
||||
agreementHtml({
|
||||
application: presented,
|
||||
signatory: {
|
||||
name: signatory.name,
|
||||
designation: signatory.designation,
|
||||
signatureDataUrl: `data:image/png;base64,${Buffer.from(signatureBytes).toString('base64')}`,
|
||||
},
|
||||
physicianSignature: await physicianSignature(application),
|
||||
draft: false,
|
||||
crestDate: todayIso(),
|
||||
}),
|
||||
);
|
||||
const sha256 = createHash('sha256').update(pdf).digest('hex');
|
||||
const pdfKey = `applications/${application.public_id}/v${application.version}/final.pdf`;
|
||||
const token = generateToken();
|
||||
let stored = false;
|
||||
|
||||
try {
|
||||
await putObject(pdfKey, pdf, 'application/pdf');
|
||||
stored = true;
|
||||
const decidedAt = new Date();
|
||||
await db.transaction(async (trx) => {
|
||||
const current = await trx('applications').where({ id }).forUpdate().first();
|
||||
|
||||
if (!current || current.status !== 'SUBMITTED') {
|
||||
throw Boom.conflict('Only a submitted application can be approved');
|
||||
}
|
||||
|
||||
await trx('applications').where({ id }).update({
|
||||
status: 'APPROVED',
|
||||
decided_at: decidedAt,
|
||||
decided_by: request.pre.admin.id,
|
||||
final_pdf_key: pdfKey,
|
||||
signed_pdf_sha256: sha256,
|
||||
});
|
||||
await trx('survey_tokens').insert({
|
||||
application_id: id,
|
||||
token_hash: hashToken(token),
|
||||
created_at: decidedAt,
|
||||
});
|
||||
await recordAudit(
|
||||
{
|
||||
actorType: 'ADMIN',
|
||||
actorId: request.pre.admin.id,
|
||||
action: 'APPLICATION_APPROVED',
|
||||
entityType: 'applications',
|
||||
entityId: id,
|
||||
ip: request.info.remoteAddress,
|
||||
metadata: { sha256 },
|
||||
},
|
||||
trx,
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
if (stored) {
|
||||
await deleteObject(pdfKey).catch(() => {});
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
await sendApprovedEmail({
|
||||
applicationId: id,
|
||||
toEmail: application.email,
|
||||
name: application.full_name,
|
||||
token,
|
||||
pdfKey,
|
||||
await finalizeSubmittedApplication(request, application, {
|
||||
decidedBy: request.pre.admin.id,
|
||||
actorType: 'ADMIN',
|
||||
actorId: request.pre.admin.id,
|
||||
action: 'APPLICATION_APPROVED',
|
||||
});
|
||||
|
||||
return getApplication(id);
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { agreementHtml } from '../pdf/agreementHtml.js';
|
|||
import { renderPdf } from '../pdf/render.js';
|
||||
import { readPngDataUrl } from './png.js';
|
||||
import { recordAudit } from './audit.js';
|
||||
import { finalizeSubmittedApplication } from './applicationApproval.js';
|
||||
import {
|
||||
applicationFromRequest,
|
||||
assertCompleteApplication,
|
||||
|
|
@ -36,9 +37,25 @@ const signSchema = z.discriminatedUnion('type', [
|
|||
*/
|
||||
export async function presentWithPdf(application) {
|
||||
const body = presentApplication(application);
|
||||
const finalKey = application.final_pdf_key ? String(application.final_pdf_key) : null;
|
||||
const signedKey = application.signed_pdf_key ? String(application.signed_pdf_key) : null;
|
||||
|
||||
if (application.signed_pdf_key) {
|
||||
body.signedPdfUrl = await getPresignedUrl(String(application.signed_pdf_key));
|
||||
if (signedKey) {
|
||||
body.signedPdfUrl = await getPresignedUrl(signedKey);
|
||||
}
|
||||
|
||||
if (finalKey) {
|
||||
body.finalPdfUrl = await getPresignedUrl(finalKey);
|
||||
}
|
||||
|
||||
// Prefer the final CREST-signed PDF for previews (same as admin).
|
||||
const previewKey = finalKey || signedKey;
|
||||
if (previewKey) {
|
||||
body.pdfUrl = previewKey === finalKey ? body.finalPdfUrl : body.signedPdfUrl;
|
||||
}
|
||||
|
||||
if (application.cancelled_cheque_key) {
|
||||
body.cancelledChequeUrl = await getPresignedUrl(String(application.cancelled_cheque_key));
|
||||
}
|
||||
|
||||
return body;
|
||||
|
|
@ -202,8 +219,22 @@ export async function signApplication(request) {
|
|||
}
|
||||
|
||||
const saved = await db('applications').where({ id: application.id }).first();
|
||||
await notifySubmission(saved);
|
||||
return presentWithPdf(saved);
|
||||
|
||||
try {
|
||||
const finalized = await finalizeSubmittedApplication(request, saved, {
|
||||
decidedBy: null,
|
||||
actorType: 'SYSTEM',
|
||||
actorId: null,
|
||||
action: 'APPLICATION_AUTO_APPROVED',
|
||||
});
|
||||
const body = await presentWithPdf(finalized.application);
|
||||
body.surveyToken = finalized.surveyToken;
|
||||
return body;
|
||||
} catch (error) {
|
||||
request.log(['warn', 'auto-approve'], error);
|
||||
await notifySubmission(saved);
|
||||
return presentWithPdf(saved);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -4,8 +4,13 @@ import { db } from '../db/knex.js';
|
|||
import { auditFromRequest } from '../middleware/audit.js';
|
||||
import { draftApplicationSchema, finalizeApplicationSchema } from '../schemas/application.js';
|
||||
import { decryptField, encryptField, panLookup } from './fieldCrypto.js';
|
||||
import { readImageDataUrl } from './png.js';
|
||||
import { deleteObject, putObject } from './storage.js';
|
||||
import { generateToken, hashToken } from './tokens.js';
|
||||
import { sendResumeLink } from './mail.js';
|
||||
import { readUserId } from './userAuth.js';
|
||||
|
||||
const CHEQUE_LIMIT = 5 * 1024 * 1024;
|
||||
|
||||
const CODE_FIELDS = ['pan', 'ifsc', 'gstin'];
|
||||
|
||||
|
|
@ -79,14 +84,50 @@ function zodMessage(error) {
|
|||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @returns {string | null}
|
||||
*/
|
||||
function jwtFromCookie(value) {
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object' && 'token' in value && typeof value.token === 'string') {
|
||||
return value.token;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the physician application from a logged-in user cookie or a resume Bearer token.
|
||||
* @param {import('@hapi/hapi').Request} request
|
||||
*/
|
||||
export async function applicationFromRequest(request) {
|
||||
const userJwt = jwtFromCookie(request.state.user_token);
|
||||
const userId = userJwt ? readUserId(userJwt) : null;
|
||||
|
||||
if (userId) {
|
||||
const user = await db('users').where({ id: Number(userId), is_active: true }).first();
|
||||
|
||||
if (!user) {
|
||||
throw Boom.unauthorized('Authentication required');
|
||||
}
|
||||
|
||||
const application = await db('applications').where({ user_id: user.id }).first();
|
||||
|
||||
if (!application) {
|
||||
throw Boom.badRequest('No application is linked to this account');
|
||||
}
|
||||
|
||||
return application;
|
||||
}
|
||||
|
||||
const header = request.headers.authorization ?? '';
|
||||
const match = header.match(/^Bearer\s+(\S+)$/i);
|
||||
|
||||
if (!match) {
|
||||
throw Boom.unauthorized('This resume link is invalid or has expired');
|
||||
throw Boom.unauthorized('Authentication required');
|
||||
}
|
||||
|
||||
const tokenRow = await db('resume_tokens')
|
||||
|
|
@ -186,10 +227,13 @@ function protectSecrets(updates) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Persist draft/finalize updates and return the updated application row.
|
||||
* Callers that need download URLs should wrap with presentWithPdf.
|
||||
* @param {import('@hapi/hapi').Request} request
|
||||
* @param {Record<string, unknown>} application
|
||||
* @param {Record<string, unknown>} payload
|
||||
* @param {boolean} finalize
|
||||
* @returns {Promise<Record<string, unknown>>}
|
||||
*/
|
||||
export async function saveApplication(request, application, payload, finalize) {
|
||||
if (application.status === 'SUBMITTED' || application.status === 'APPROVED') {
|
||||
|
|
@ -204,13 +248,36 @@ export async function saveApplication(request, application, payload, finalize) {
|
|||
|
||||
/** @type {Record<string, unknown>} */
|
||||
const updates = {};
|
||||
const chequePayload = parsed.data.cancelled_cheque;
|
||||
|
||||
for (const [key, value] of Object.entries(parsed.data)) {
|
||||
if (value !== undefined) {
|
||||
if (value !== undefined && key !== 'cancelled_cheque') {
|
||||
updates[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (chequePayload === null) {
|
||||
if (application.cancelled_cheque_key) {
|
||||
await deleteObject(String(application.cancelled_cheque_key)).catch(() => {});
|
||||
}
|
||||
updates.cancelled_cheque_key = null;
|
||||
} else if (typeof chequePayload === 'string') {
|
||||
const image = readImageDataUrl(
|
||||
chequePayload,
|
||||
CHEQUE_LIMIT,
|
||||
'The cancelled cheque image must be 5MB or smaller',
|
||||
);
|
||||
const key = `applications/${application.public_id}/v${application.version}/cancelled-cheque.${image.extension}`;
|
||||
await putObject(key, image.buffer, image.contentType);
|
||||
if (
|
||||
application.cancelled_cheque_key &&
|
||||
String(application.cancelled_cheque_key) !== key
|
||||
) {
|
||||
await deleteObject(String(application.cancelled_cheque_key)).catch(() => {});
|
||||
}
|
||||
updates.cancelled_cheque_key = key;
|
||||
}
|
||||
|
||||
if (finalize) {
|
||||
const merged = {
|
||||
...presentApplication(application),
|
||||
|
|
@ -284,6 +351,5 @@ export async function saveApplication(request, application, payload, finalize) {
|
|||
metadata: { finalize },
|
||||
});
|
||||
|
||||
const saved = await db('applications').where({ id: application.id }).first();
|
||||
return presentApplication(saved);
|
||||
return db('applications').where({ id: application.id }).first();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -53,11 +53,23 @@ function layout(content) {
|
|||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @param {{ url?: string, name?: string, reason?: string }} data
|
||||
* @param {{ url?: string, name?: string, reason?: string, otp?: string }} data
|
||||
*/
|
||||
export function renderEmail(name, data) {
|
||||
const physician = data.name || 'Doctor';
|
||||
|
||||
if (name === 'login_otp') {
|
||||
return layout({
|
||||
subject: 'Your sign-in code',
|
||||
heading: 'Your sign-in code',
|
||||
paragraphs: [
|
||||
'Use this one-time code to sign in to the Adherence Pack Study. It expires in 10 minutes.',
|
||||
data.otp || '',
|
||||
'If you did not request this code, you can ignore this email.',
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (name === 'resume_link') {
|
||||
return layout({
|
||||
subject: 'Continue your agreement',
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { renderEmail } from './emailTemplates.js';
|
|||
import { getMailFromName } from './settings.js';
|
||||
import { getObject } from './storage.js';
|
||||
|
||||
/** @type {{ toEmail: string, url: string, applicationId: number, template?: string }[]} */
|
||||
/** @type {{ toEmail: string, url: string, applicationId: number, template?: string, otp?: string }[]} */
|
||||
export const outbox = [];
|
||||
|
||||
export function clearOutbox() {
|
||||
|
|
@ -102,7 +102,7 @@ async function deliverToSendGrid(message) {
|
|||
* Send one template. Failures are stored on email_log and are not rethrown.
|
||||
* @param {string} name
|
||||
* @param {string} to
|
||||
* @param {{ applicationId: number, url?: string, name?: string, reason?: string, pdfKey?: string | null, reminderCount?: number }} data
|
||||
* @param {{ applicationId: number, url?: string, name?: string, reason?: string, otp?: string, pdfKey?: string | null, reminderCount?: number }} data
|
||||
*/
|
||||
export async function sendTemplate(name, to, data) {
|
||||
/** @type {number | undefined} */
|
||||
|
|
@ -137,6 +137,7 @@ export async function sendTemplate(name, to, data) {
|
|||
url: data.url ?? '',
|
||||
applicationId: data.applicationId,
|
||||
template: name,
|
||||
otp: data.otp,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -187,6 +188,16 @@ export async function sendTemplate(name, to, data) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ applicationId: number, toEmail: string, otp: string }} message
|
||||
*/
|
||||
export async function sendLoginOtp(message) {
|
||||
await sendTemplate('login_otp', message.toEmail, {
|
||||
applicationId: message.applicationId,
|
||||
otp: message.otp,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ applicationId: number, toEmail: string, token: string }} message
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -42,3 +42,41 @@ export function readPngDataUrl(image, maxBytes, sizeMessage) {
|
|||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a PNG or JPEG data URL for optional document uploads (e.g. cancelled cheque).
|
||||
* @param {string} image
|
||||
* @param {number} maxBytes
|
||||
* @param {string} sizeMessage
|
||||
* @returns {{ buffer: Buffer, contentType: 'image/png' | 'image/jpeg', extension: 'png' | 'jpg' }}
|
||||
*/
|
||||
export function readImageDataUrl(image, maxBytes, sizeMessage) {
|
||||
const match = /^data:image\/(png|jpeg);base64,([A-Za-z0-9+/]+={0,2})$/.exec(image);
|
||||
|
||||
if (!match) {
|
||||
throw Boom.badRequest('Upload a PNG or JPEG image');
|
||||
}
|
||||
|
||||
const kind = /** @type {'png' | 'jpeg'} */ (match[1]);
|
||||
const buffer = Buffer.from(match[2], 'base64');
|
||||
|
||||
if (buffer.length === 0 || buffer.length > maxBytes) {
|
||||
throw Boom.badRequest(sizeMessage);
|
||||
}
|
||||
|
||||
if (kind === 'png') {
|
||||
const png = buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47;
|
||||
if (!png || buffer.length < 8) {
|
||||
throw Boom.badRequest('Upload a PNG or JPEG image');
|
||||
}
|
||||
|
||||
return { buffer, contentType: 'image/png', extension: 'png' };
|
||||
}
|
||||
|
||||
const jpeg = buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff;
|
||||
if (!jpeg) {
|
||||
throw Boom.badRequest('Upload a PNG or JPEG image');
|
||||
}
|
||||
|
||||
return { buffer, contentType: 'image/jpeg', extension: 'jpg' };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ const LOCAL_ROOT = path.resolve(
|
|||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'../../.local-storage',
|
||||
);
|
||||
const URL_TTL_MS = 5 * 60 * 1000;
|
||||
const URL_TTL_MS = 60 * 60 * 1000;
|
||||
|
||||
/** @type {S3Client | undefined} */
|
||||
let s3;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import Boom from '@hapi/boom';
|
||||
import { db } from '../db/knex.js';
|
||||
import { recordAudit } from './audit.js';
|
||||
import { hashToken } from './tokens.js';
|
||||
import { generateToken, hashToken } from './tokens.js';
|
||||
import { knownAnswers, validateAnswers } from '../survey/validate.js';
|
||||
import { stripHtmlDeep } from './sanitize.js';
|
||||
|
||||
|
|
@ -84,6 +84,46 @@ function present(definitionRow, response, application) {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue (or rotate) a survey link for a logged-in physician whose agreement is approved.
|
||||
* @param {number} userId
|
||||
*/
|
||||
export async function issueSurveyLinkForUser(userId) {
|
||||
const application = await db('applications').where({ user_id: userId }).first();
|
||||
|
||||
if (!application) {
|
||||
throw Boom.badRequest('No application is linked to this account');
|
||||
}
|
||||
|
||||
if (application.status !== 'APPROVED') {
|
||||
throw Boom.conflict('The survey is available after the agreement is approved');
|
||||
}
|
||||
|
||||
const existing = await db('survey_responses').where({ application_id: application.id }).first();
|
||||
|
||||
if (existing?.status === 'COMPLETED') {
|
||||
throw Boom.conflict('This survey has already been submitted');
|
||||
}
|
||||
|
||||
const token = generateToken();
|
||||
const createdAt = new Date();
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
await trx('survey_tokens')
|
||||
.where({ application_id: application.id })
|
||||
.whereNull('used_at')
|
||||
.whereNull('revoked_at')
|
||||
.update({ revoked_at: createdAt });
|
||||
await trx('survey_tokens').insert({
|
||||
application_id: application.id,
|
||||
token_hash: hashToken(token),
|
||||
created_at: createdAt,
|
||||
});
|
||||
});
|
||||
|
||||
return { token, url: `/survey/${token}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} token
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
export const SURVEY_TITLE =
|
||||
"Physicians' Perspectives on Patient Adherence to Inhaled Maintenance Therapy";
|
||||
"Physicians' Perceptions of the Impact of an Extended-Dose Pack versus Regular Pack on Adherence to Inhaled Maintenance Medication in Asthma and COPD: A Cross-Sectional Survey";
|
||||
|
||||
const packRows = [
|
||||
{ id: 'regular_pmdi', label: 'Regular pMDI' },
|
||||
{ id: 'regular_rotacap', label: 'Regular Rotacap' },
|
||||
{ id: 'extended_pmdi', label: 'Extended-Dose pMDI' },
|
||||
{ id: 'extended_rotacap', label: 'Extended-Dose Rotacap' },
|
||||
{ id: 'regular_pmdi', label: 'Regular Pack — Formoterol/Budesonide 200 & 400 mcg pMDI (120 doses)' },
|
||||
{ id: 'regular_rotacap', label: 'Regular Pack — Rotacap (pack of 30)' },
|
||||
{ id: 'extended_pmdi', label: 'Extended-Dose Pack — Formoterol/Budesonide 200 & 400 mcg pMDI (180 doses)' },
|
||||
{ id: 'extended_rotacap', label: 'Extended-Dose Pack — Rotacap (pack of 60)' },
|
||||
];
|
||||
|
||||
const packColumns = [
|
||||
|
|
@ -21,17 +21,19 @@ const likertScale = [
|
|||
{ id: 'strongly_agree', label: 'Strongly Agree' },
|
||||
];
|
||||
|
||||
/** Version 1 of the adherence pack questionnaire. */
|
||||
/** Survey Questionnaire Version 01 (14/10/2026). */
|
||||
export const surveyDefinition = {
|
||||
version: '01',
|
||||
versionDate: '2026-10-14',
|
||||
sections: [
|
||||
{
|
||||
id: 'demographics',
|
||||
title: 'Basic Demographics / Doctor details',
|
||||
title: 'Basic Demographics / Doctor Details',
|
||||
questions: [
|
||||
{
|
||||
id: 'subtitle',
|
||||
type: 'help',
|
||||
text: 'A Cross-Sectional Comparison of Extended-Dose and Standard Packaging',
|
||||
text: SURVEY_TITLE,
|
||||
},
|
||||
{ id: 'name', type: 'short_text', label: 'Name', required: false },
|
||||
{
|
||||
|
|
@ -42,13 +44,11 @@ export const surveyDefinition = {
|
|||
other: true,
|
||||
options: [
|
||||
{ id: 'mbbs', label: 'MBBS' },
|
||||
{ id: 'md_chest', label: 'MD Chest' },
|
||||
{ id: 'dm_pulmo', label: 'DM Pulmo' },
|
||||
{ id: 'dnb', label: 'DNB' },
|
||||
{ id: 'd3ch', label: 'D3CH' },
|
||||
{ id: 'md_med', label: 'MD Med' },
|
||||
{ id: 'dnb_med', label: 'DNB Med' },
|
||||
{ id: 'other', label: 'Any other, please specify' },
|
||||
{ id: 'md_chest', label: 'MD (Chest)' },
|
||||
{ id: 'dm_pulmo_dnb_dch', label: 'DM (Pulmo / DNB / DCH)' },
|
||||
{ id: 'md_med', label: 'MD (Med)' },
|
||||
{ id: 'dnb_med', label: 'DNB (Med)' },
|
||||
{ id: 'other', label: 'Any other (specify)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
@ -80,8 +80,7 @@ export const surveyDefinition = {
|
|||
options: [
|
||||
{ id: 'chest', label: 'Chest Physician' },
|
||||
{ id: 'consulting', label: 'Consulting Physician' },
|
||||
{ id: 'general', label: 'General Physician' },
|
||||
{ id: 'other', label: 'Other (please specify)' },
|
||||
{ id: 'other', label: 'Other (specify)' },
|
||||
],
|
||||
},
|
||||
{ id: 'city', type: 'short_text', label: 'City', required: false },
|
||||
|
|
@ -90,12 +89,12 @@ export const surveyDefinition = {
|
|||
},
|
||||
{
|
||||
id: 'practice',
|
||||
title: 'Physician practice profile',
|
||||
title: 'Physician Practice Profile',
|
||||
questions: [
|
||||
{
|
||||
id: 'patients_per_month',
|
||||
type: 'number',
|
||||
label: 'On an average how many patients do you see per month?',
|
||||
label: 'On an average, how many patients do you see per month?',
|
||||
required: true,
|
||||
integer: true,
|
||||
min: 0,
|
||||
|
|
@ -151,13 +150,13 @@ export const surveyDefinition = {
|
|||
},
|
||||
{
|
||||
id: 'prescribing',
|
||||
title: 'Foracort prescribing pattern',
|
||||
title: 'Foracort Prescribing Pattern',
|
||||
questions: [
|
||||
{
|
||||
id: 'asthma_formoterol_pct',
|
||||
type: 'percentage',
|
||||
label:
|
||||
'Approximately what proportion of your asthma patients receive Formoterol/Budesonide',
|
||||
'Approximately what proportion of your asthma patients receive Formoterol/Budesonide?',
|
||||
required: true,
|
||||
min: 0,
|
||||
max: 100,
|
||||
|
|
@ -166,16 +165,28 @@ export const surveyDefinition = {
|
|||
id: 'copd_formoterol_pct',
|
||||
type: 'percentage',
|
||||
label:
|
||||
'Approximately what proportion of your COPD patients receive Formoterol/Budesonide',
|
||||
'Approximately what proportion of your COPD patients receive Formoterol/Budesonide?',
|
||||
required: true,
|
||||
min: 0,
|
||||
max: 100,
|
||||
},
|
||||
{
|
||||
id: 'extended_dose_duration',
|
||||
type: 'single_choice',
|
||||
label:
|
||||
'For how long have you been prescribing the Extended-Dose Pack to your patients with asthma and/or COPD?',
|
||||
required: true,
|
||||
options: [
|
||||
{ id: 'less_than_6_months', label: 'Less than 6 months' },
|
||||
{ id: 'six_months_to_one_year', label: '6 months to less than 1 year' },
|
||||
{ id: 'more_than_one_year', label: 'More than 1 year' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'pack_intro',
|
||||
type: 'section_header',
|
||||
label:
|
||||
'Among your patients receiving Foracort, approximately what proportion do you prescribe:',
|
||||
'Among your patients receiving Foracort, approximately what proportion do you prescribe (each column must total 100%):',
|
||||
},
|
||||
{
|
||||
id: 'pack_split',
|
||||
|
|
@ -188,31 +199,26 @@ export const surveyDefinition = {
|
|||
max: 100,
|
||||
sumTo: 100,
|
||||
},
|
||||
{
|
||||
id: 'pack_note_regular',
|
||||
type: 'help',
|
||||
text: '1: Regular Pack - Formoterol/Budesonide 200 & 400mcg - Rotacaps (pack of 30) & MDI (120 doses)',
|
||||
},
|
||||
{
|
||||
id: 'pack_note_extended',
|
||||
type: 'help',
|
||||
text: '2: Extended-Dose Pack - Formoterol/Budesonide 200 & 400 mcg - Rotacaps (pack of 60) & MDI (180 doses)',
|
||||
},
|
||||
{
|
||||
id: 'pack_note_cost',
|
||||
type: 'help',
|
||||
text: 'Note: The unit dose cost for DPI & MDI remains same irrespective of the pack size',
|
||||
text: 'Note: Unit dose cost is the same for Regular and Extended-Dose packs (pMDI 120 vs 180 doses; Rotacap pack of 30 vs 60).',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'adherence',
|
||||
title: 'Physician assessment of adherence',
|
||||
title: 'Physician Assessment of Adherence',
|
||||
questions: [
|
||||
{
|
||||
id: 'adherence_prompt',
|
||||
type: 'help',
|
||||
text: 'Based on your clinical experience, approximately what proportion of patients are adherent to their prescribed treatment? (Please provide your estimate out of 100 patients)',
|
||||
text: 'Based on your clinical experience, approximately what proportion of patients are adherent to their prescribed treatment? (Estimate out of 100 patients.)',
|
||||
},
|
||||
{
|
||||
id: 'adherence_definition',
|
||||
type: 'help',
|
||||
text: 'A patient is considered "adherent" if the physician believes the patient takes at least 75% of the prescribed inhaled maintenance medication doses during a typical month of treatment.',
|
||||
},
|
||||
{
|
||||
id: 'adherence_estimate',
|
||||
|
|
@ -230,7 +236,7 @@ export const surveyDefinition = {
|
|||
},
|
||||
{
|
||||
id: 'comparison',
|
||||
title: 'Physician assessment of adherence',
|
||||
title: 'Physician Assessment of Adherence',
|
||||
questions: [
|
||||
{
|
||||
id: 'adherence_comparison',
|
||||
|
|
@ -251,13 +257,13 @@ export const surveyDefinition = {
|
|||
},
|
||||
{
|
||||
id: 'barriers',
|
||||
title: 'Reasons for non-adherence',
|
||||
title: 'Reasons for Non-adherence',
|
||||
questions: [
|
||||
{
|
||||
id: 'barriers',
|
||||
type: 'multi_choice',
|
||||
label:
|
||||
'In your opinion What are the key barriers to adherence to inhaled maintenance therapy of formoterol/budesonide in your asthma and COPD patients?',
|
||||
'In your opinion, what are the key barriers to adherence to inhaled maintenance therapy of formoterol/budesonide in your asthma and COPD patients?',
|
||||
required: true,
|
||||
other: true,
|
||||
options: [
|
||||
|
|
@ -276,27 +282,27 @@ export const surveyDefinition = {
|
|||
{ id: 'remembering', label: 'Difficulty remembering to take doses' },
|
||||
{ id: 'technique', label: 'Inhaler technique difficulties' },
|
||||
{ id: 'lifestyle', label: 'Patient lifestyle/work-related factors' },
|
||||
{ id: 'other', label: 'Other (Specify)' },
|
||||
{ id: 'other', label: 'Other (specify)' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'benefit',
|
||||
title: 'Patient characteristics associated with adherence',
|
||||
title: 'Patient Characteristics Associated with Adherence',
|
||||
questions: [
|
||||
{
|
||||
id: 'benefit',
|
||||
type: 'multi_choice',
|
||||
label:
|
||||
'Which patients, in your opinion, are most likely to benefit from an Extended dose Pack?',
|
||||
'Which patients, in your opinion, are most likely to benefit from an Extended-dose pack?',
|
||||
required: true,
|
||||
other: true,
|
||||
options: [
|
||||
{ id: 'elderly', label: 'Elderly patients' },
|
||||
{
|
||||
id: 'access',
|
||||
label: 'Difficulty in accessing the pharmacy for a refill (e.g. Remote location)',
|
||||
label: 'Difficulty accessing the pharmacy for a refill (e.g., remote location)',
|
||||
},
|
||||
{ id: 'exacerbations', label: 'Patients with frequent exacerbations' },
|
||||
{ id: 'forgetting', label: 'Patients frequently forgetting doses' },
|
||||
|
|
@ -306,7 +312,7 @@ export const surveyDefinition = {
|
|||
id: 'maintenance',
|
||||
label: 'All patients requiring long-term maintenance therapy',
|
||||
},
|
||||
{ id: 'busy', label: 'Busy Lifestyle / working professional' },
|
||||
{ id: 'busy', label: 'Busy lifestyle / working professional' },
|
||||
{ id: 'travellers', label: 'Frequent travellers' },
|
||||
{ id: 'caregiver', label: 'Caregiver-dependent patients' },
|
||||
{ id: 'other', label: 'Other' },
|
||||
|
|
@ -316,13 +322,13 @@ export const surveyDefinition = {
|
|||
},
|
||||
{
|
||||
id: 'perspective',
|
||||
title: 'Physician perspective on the Extended-Dose Pack',
|
||||
title: 'Physician Perspective on the Extended-Dose Pack',
|
||||
questions: [
|
||||
{
|
||||
id: 'perspective_intro',
|
||||
type: 'section_header',
|
||||
label:
|
||||
'What do you consider the main advantages / disadvantages of the Extended-Dose Pack compared to Regular Pack',
|
||||
'What do you consider the main advantages / disadvantages of the Extended-Dose Pack compared to the Regular Pack?',
|
||||
},
|
||||
{ id: 'advantages', type: 'long_text', label: 'Advantages', required: true },
|
||||
{ id: 'disadvantages', type: 'long_text', label: 'Disadvantages', required: true },
|
||||
|
|
@ -336,13 +342,13 @@ export const surveyDefinition = {
|
|||
id: 'likert_intro',
|
||||
type: 'section_header',
|
||||
label:
|
||||
'Overall, how would you rate the Extended-Dose Pack compared with the Regular Pack with respect to:',
|
||||
'Overall, how would you rate the Extended-Dose Pack compared with the Regular Pack with respect to the following?',
|
||||
},
|
||||
{
|
||||
id: 'likert',
|
||||
type: 'likert_matrix',
|
||||
label:
|
||||
'Overall, how would you rate the Extended-Dose Pack compared with the Regular Pack with respect to:',
|
||||
'Overall, how would you rate the Extended-Dose Pack compared with the Regular Pack with respect to the following?',
|
||||
required: true,
|
||||
scale: likertScale,
|
||||
rows: [
|
||||
|
|
|
|||
|
|
@ -1,20 +1,34 @@
|
|||
import { db } from '../db/knex.js';
|
||||
import { SURVEY_TITLE, surveyDefinition } from './definition.js';
|
||||
|
||||
/** Questionnaire V01 (14/10/2026). */
|
||||
export const SURVEY_DEFINITION_VERSION = 2;
|
||||
|
||||
/**
|
||||
* Insert version 1 when it is missing. Leave an existing version 1 unchanged.
|
||||
* Ensure the active survey definition matches the current V01 questionnaire.
|
||||
* Existing in-progress responses stay pinned to their original definition id.
|
||||
* @param {import('knex').Knex | import('knex').Knex.Transaction} [connection]
|
||||
*/
|
||||
export async function seedSurveyDefinition(connection = db) {
|
||||
const existing = await connection('survey_definitions').where({ version: 1 }).first();
|
||||
const existing = await connection('survey_definitions')
|
||||
.where({ version: SURVEY_DEFINITION_VERSION })
|
||||
.first();
|
||||
|
||||
if (existing) {
|
||||
await connection('survey_definitions').where({ is_active: true }).update({ is_active: false });
|
||||
await connection('survey_definitions')
|
||||
.where({ id: existing.id })
|
||||
.update({
|
||||
is_active: true,
|
||||
title: SURVEY_TITLE,
|
||||
definition: JSON.stringify(surveyDefinition),
|
||||
});
|
||||
return existing.id;
|
||||
}
|
||||
|
||||
await connection('survey_definitions').where({ is_active: true }).update({ is_active: false });
|
||||
const [id] = await connection('survey_definitions').insert({
|
||||
version: 1,
|
||||
version: SURVEY_DEFINITION_VERSION,
|
||||
title: SURVEY_TITLE,
|
||||
definition: JSON.stringify(surveyDefinition),
|
||||
is_active: true,
|
||||
|
|
|
|||
|
|
@ -25,12 +25,16 @@ describe('agreement HTML', () => {
|
|||
draft: true,
|
||||
});
|
||||
|
||||
expect(html).toContain('is is executed');
|
||||
expect(html).toContain('is executed on this');
|
||||
expect(html).not.toContain('is is executed');
|
||||
expect(html).toContain('<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>… PARTY OF THE FIRST PART</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).not.toContain('<strong>1.1 CREST');
|
||||
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">Medical Director</span>');
|
||||
expect(html).toContain('data:image/png;base64,AAAA');
|
||||
expect(html).toContain('class="signature-row"');
|
||||
expect(html).toContain('alt="CREST"');
|
||||
expect(html).not.toContain('Pending approval');
|
||||
expect(html).not.toContain('DRAFT - NOT SIGNED');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -269,6 +269,11 @@ describe('applications', () => {
|
|||
});
|
||||
expect(missingConsent.statusCode).toBe(400);
|
||||
|
||||
// Ensure auto-approve cannot run so this case stays SUBMITTED.
|
||||
await db('settings')
|
||||
.where({ key: 'signatory_signature_key' })
|
||||
.update({ value: JSON.stringify('') });
|
||||
|
||||
const signed = await api.inject({
|
||||
method: 'POST',
|
||||
url: '/api/applications/me/sign',
|
||||
|
|
@ -277,6 +282,7 @@ describe('applications', () => {
|
|||
});
|
||||
|
||||
expect(signed.statusCode).toBe(200);
|
||||
// Without a CREST signatory image, auto-approve fails and the row stays SUBMITTED.
|
||||
expect(signed.result).toMatchObject({ status: 'SUBMITTED' });
|
||||
expect(signed.result.signedPdfUrl).toEqual(expect.any(String));
|
||||
const row = await db('applications').where({ email }).first();
|
||||
|
|
@ -284,6 +290,48 @@ describe('applications', () => {
|
|||
expect(row.submitted_at).toBeTruthy();
|
||||
});
|
||||
|
||||
it('auto-approves on sign when the CREST signature is configured', async () => {
|
||||
const { putObject } = await import('../src/services/storage.js');
|
||||
const api = await start();
|
||||
await postEmail(api);
|
||||
const headers = { authorization: `Bearer ${tokenFrom(outbox[0].url)}` };
|
||||
|
||||
await api.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/applications/me?finalize=true',
|
||||
headers,
|
||||
payload: complete,
|
||||
});
|
||||
|
||||
const png = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
|
||||
'base64',
|
||||
);
|
||||
await putObject('settings/signatory-signature.png', png, 'image/png');
|
||||
await db('settings')
|
||||
.where({ key: 'signatory_signature_key' })
|
||||
.update({ value: JSON.stringify('settings/signatory-signature.png') });
|
||||
|
||||
const signed = await api.inject({
|
||||
method: 'POST',
|
||||
url: '/api/applications/me/sign',
|
||||
headers,
|
||||
payload: { type: 'TYPED', typedName: 'Asha Rao', consent: true },
|
||||
});
|
||||
|
||||
expect(signed.statusCode).toBe(200);
|
||||
expect(signed.result).toMatchObject({ status: 'APPROVED' });
|
||||
expect(signed.result.surveyToken).toEqual(expect.any(String));
|
||||
const row = await db('applications').where({ email }).first();
|
||||
expect(row.status).toBe('APPROVED');
|
||||
expect(row.final_pdf_key).toBeTruthy();
|
||||
expect(row.decided_by).toBeNull();
|
||||
expect(
|
||||
await db('audit_log').where({ action: 'APPLICATION_AUTO_APPROVED' }).first(),
|
||||
).toBeTruthy();
|
||||
expect(await db('survey_tokens').where({ application_id: row.id }).first()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('returns a rejected application to draft from edit and keeps the reason', async () => {
|
||||
const api = await start();
|
||||
await postEmail(api);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -163,9 +163,9 @@ describe('agreement email', () => {
|
|||
const first = await api.inject({ method: 'GET', url: `/api/survey/${token}` });
|
||||
const second = await api.inject({ method: 'GET', url: `/api/survey/${token}` });
|
||||
expect(first.statusCode).toBe(200);
|
||||
expect(first.result.version).toBe(1);
|
||||
expect(first.result.version).toBe(2);
|
||||
expect(second.statusCode).toBe(200);
|
||||
expect(second.result.version).toBe(1);
|
||||
expect(second.result.version).toBe(2);
|
||||
|
||||
await db('survey_tokens').where({ application_id: id }).update({ used_at: new Date() });
|
||||
const used = await api.inject({ method: 'GET', url: `/api/survey/${token}` });
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { EventEmitter } from 'node:events';
|
||||
import { access, writeFile } from 'node:fs/promises';
|
||||
import { access, readFile, writeFile } from 'node:fs/promises';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const spawn = vi.hoisted(() => vi.fn());
|
||||
|
|
@ -38,12 +38,39 @@ describe('renderPdf', () => {
|
|||
expect(spawn).toHaveBeenCalledWith(
|
||||
'weasyprint',
|
||||
expect.any(Array),
|
||||
expect.objectContaining({ stdio: ['ignore', 'ignore', 'pipe'] }),
|
||||
expect.objectContaining({
|
||||
stdio: ['ignore', 'ignore', 'pipe'],
|
||||
cwd: expect.any(String),
|
||||
}),
|
||||
);
|
||||
const htmlPath = spawn.mock.calls[0][1][0];
|
||||
await expect(access(htmlPath)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('writes data-URI images beside the HTML for WeasyPrint', async () => {
|
||||
/** @type {string | undefined} */
|
||||
let writtenHtml;
|
||||
|
||||
spawn.mockImplementation((_bin, args) => {
|
||||
const proc = child();
|
||||
queueMicrotask(async () => {
|
||||
writtenHtml = await readFile(args[0], 'utf8');
|
||||
await writeFile(args[1], Buffer.from('%PDF-1.4'));
|
||||
proc.emit('close', 0);
|
||||
});
|
||||
return proc;
|
||||
});
|
||||
|
||||
const tiny =
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==';
|
||||
await renderPdf(
|
||||
`<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 () => {
|
||||
spawn.mockImplementation(() => {
|
||||
const proc = child();
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ describe('local storage', () => {
|
|||
await deleteObject(key);
|
||||
});
|
||||
|
||||
it('stores an object and returns a five-minute signed URL', async () => {
|
||||
it('stores an object and returns a one-hour signed URL', async () => {
|
||||
await putObject(key, Buffer.from('hello'));
|
||||
|
||||
expect((await getObject(key)).toString()).toBe('hello');
|
||||
|
|
@ -24,8 +24,8 @@ describe('local storage', () => {
|
|||
const signature = url.searchParams.get('sig');
|
||||
|
||||
expect(url.pathname).toContain(encodeURIComponent(key));
|
||||
expect(Number(expires) - Date.now()).toBeGreaterThan(4 * 60 * 1000);
|
||||
expect(Number(expires) - Date.now()).toBeLessThanOrEqual(5 * 60 * 1000);
|
||||
expect(Number(expires) - Date.now()).toBeGreaterThan(55 * 60 * 1000);
|
||||
expect(Number(expires) - Date.now()).toBeLessThanOrEqual(60 * 60 * 1000);
|
||||
expect(verifyLocalSignature(key, expires, signature)).toBe(true);
|
||||
expect(verifyLocalSignature(key, String(Date.now() - 1000), signature)).toBe(false);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -258,7 +258,7 @@ describe('survey submission', () => {
|
|||
const { applicationId, token } = await issueToken();
|
||||
const opened = await api.inject({ method: 'GET', url: `/api/survey/${token}` });
|
||||
expect(opened.statusCode).toBe(200);
|
||||
expect(opened.result.version).toBe(1);
|
||||
expect(opened.result.version).toBe(2);
|
||||
expect(opened.result.answers.name).toBe('Asha Rao');
|
||||
|
||||
const saved = await api.inject({
|
||||
|
|
@ -270,15 +270,15 @@ describe('survey submission', () => {
|
|||
expect(saved.result.answers.city).toBe('Pune');
|
||||
expect(saved.result.answers.ignored).toBeUndefined();
|
||||
|
||||
await db('survey_definitions').where({ version: 1 }).update({ is_active: false });
|
||||
await db('survey_definitions').where({ version: 2 }).update({ is_active: false });
|
||||
await db('survey_definitions').insert({
|
||||
version: 2,
|
||||
version: 3,
|
||||
title: 'Replacement survey',
|
||||
definition: JSON.stringify({ sections: [] }),
|
||||
is_active: true,
|
||||
});
|
||||
const pinned = await api.inject({ method: 'GET', url: `/api/survey/${token}` });
|
||||
expect(pinned.result.version).toBe(1);
|
||||
expect(pinned.result.version).toBe(2);
|
||||
expect(pinned.result.answers.city).toBe('Pune');
|
||||
|
||||
const answers = completeAnswers(surveyDefinition);
|
||||
|
|
|
|||
|
|
@ -83,8 +83,11 @@ describe('survey export', () => {
|
|||
await workbook.xlsx.load(response.rawPayload);
|
||||
const responses = workbook.getWorksheet('Responses');
|
||||
const headers = responses.getRow(1).values.slice(1);
|
||||
expect(headers).toContain('Prescribing % - Extended-Dose Rotacap - COPD');
|
||||
const column = headers.indexOf('Prescribing % - Extended-Dose Rotacap - COPD') + 1;
|
||||
expect(headers).toContain(
|
||||
'Prescribing % - Extended-Dose Pack — Rotacap (pack of 60) - COPD',
|
||||
);
|
||||
const column =
|
||||
headers.indexOf('Prescribing % - Extended-Dose Pack — Rotacap (pack of 60) - COPD') + 1;
|
||||
expect(responses.getRow(2).getCell(column).value).toBe(25);
|
||||
const questions = workbook.getWorksheet('Questions');
|
||||
const questionIds = questions.getColumn(1).values;
|
||||
|
|
|
|||
|
|
@ -8,7 +8,10 @@ const TABLES = [
|
|||
'survey_definitions',
|
||||
'signatures',
|
||||
'resume_tokens',
|
||||
'consents',
|
||||
'login_otps',
|
||||
'applications',
|
||||
'users',
|
||||
'admin_users',
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
CONSULTATION AGREEMENT
|
||||
|
||||
This Consultation Agreement is is executed on this ___ day of ________ 2026 at Pune, Maharashtra.
|
||||
This Consultation Agreement is executed on this ___ day of ________ 2026 at Pune, Maharashtra.
|
||||
|
||||
BETWEEN
|
||||
|
||||
CREST Private Limited, a company incorporated under the provisions of the Companies Act, 2013, having its registered office at Pune, India.
|
||||
Chest Research And Training Private Limited, a company incorporated under the provisions of the Companies Act, 2013, having its registered office at Pune, India.
|
||||
|
||||
(hereinafter referred to as "CREST", which expression shall, unless repugnant to the context or meaning thereof, include its successors and permitted assigns);
|
||||
|
||||
|
|
@ -20,7 +20,7 @@ Dr. ______________________________, aged ___ years, residing at ________________
|
|||
|
||||
CREST and the Physician are hereinafter individually referred to as a "Party" and collectively as the "Parties."
|
||||
|
||||
WHEREAS, CREST Private Limited ("CREST") has been engaged by Cipla Limited ("Sponsor") to conduct an independent physician opinion survey titled "Foracort Adherence Pack Study" for the purpose of collecting professional medical opinions from practicing physicians.
|
||||
WHEREAS, Chest Research And Training Private Limited ("CREST") has been engaged by Cipla Limited ("Sponsor") to conduct an independent physician opinion survey titled "Physicians' Perceptions of the Impact of an Extended-Dose Pack versus Regular Pack on Adherence to Inhaled Maintenance Medication in Asthma and COPD: A Cross-Sectional Survey" for the purpose of collecting professional medical opinions from practicing physicians.
|
||||
|
||||
AND WHEREAS, the said study is intended solely to obtain independent professional opinions through a structured online questionnaire for research, scientific, educational, and analytical purposes, and does not involve the promotion, endorsement, or prescription of any pharmaceutical product.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -8,6 +8,7 @@
|
|||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev:remote": "API_PROXY_TARGET=https://pppaimt-crest.xltr.in vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run"
|
||||
|
|
|
|||
|
|
@ -15,9 +15,13 @@ import ReviewPage from './pages/ReviewPage.jsx';
|
|||
import SignPage from './pages/SignPage.jsx';
|
||||
import SubmittedPage from './pages/SubmittedPage.jsx';
|
||||
import SurveyPage from './pages/SurveyPage.jsx';
|
||||
import ConsentPage from './pages/ConsentPage.jsx';
|
||||
import UserDashboardPage from './pages/UserDashboardPage.jsx';
|
||||
import UserLoginPage from './pages/UserLoginPage.jsx';
|
||||
import SiteFooter from './SiteFooter.jsx';
|
||||
import SiteHeader from './SiteHeader.jsx';
|
||||
import TokenReferrer from './TokenReferrer.jsx';
|
||||
import UserLayout from './user/UserLayout.jsx';
|
||||
|
||||
function PublicShell({ children }) {
|
||||
return (
|
||||
|
|
@ -32,14 +36,16 @@ function PublicShell({ children }) {
|
|||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<PublicShell>
|
||||
<AgreementPage />
|
||||
</PublicShell>
|
||||
}
|
||||
/>
|
||||
<Route path="/" element={<Navigate to="/login" replace />} />
|
||||
<Route path="/login" element={<UserLoginPage />} />
|
||||
<Route element={<UserLayout />}>
|
||||
<Route path="/dashboard" element={<UserDashboardPage />} />
|
||||
<Route path="/consent" element={<ConsentPage />} />
|
||||
<Route path="/agreement" element={<AgreementPage />} />
|
||||
<Route path="/review" element={<ReviewPage />} />
|
||||
<Route path="/sign" element={<SignPage />} />
|
||||
<Route path="/submitted" element={<SubmittedPage />} />
|
||||
</Route>
|
||||
<Route
|
||||
path="/privacy"
|
||||
element={
|
||||
|
|
@ -48,7 +54,7 @@ export default function App() {
|
|||
</PublicShell>
|
||||
}
|
||||
/>
|
||||
<Route path="/start" element={<Navigate to="/" replace />} />
|
||||
<Route path="/start" element={<Navigate to="/login" replace />} />
|
||||
<Route
|
||||
path="/resume/:token"
|
||||
element={
|
||||
|
|
@ -58,38 +64,6 @@ export default function App() {
|
|||
</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
|
||||
path="/survey/:token"
|
||||
element={
|
||||
|
|
|
|||
|
|
@ -1,11 +1,24 @@
|
|||
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 { describe, expect, it } from 'vitest';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import App from './App.jsx';
|
||||
|
||||
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();
|
||||
|
||||
render(
|
||||
|
|
@ -16,9 +29,60 @@ describe('home route', () => {
|
|||
</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.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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -27,8 +27,14 @@ export async function api(path, options = {}) {
|
|||
const response = await fetch(path, {
|
||||
method: options.method ?? 'GET',
|
||||
headers,
|
||||
credentials: 'include',
|
||||
body: options.body ? JSON.stringify(options.body) : undefined,
|
||||
});
|
||||
|
||||
if (response.status === 204) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json().catch(() => ({}));
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
}
|
||||
|
|
@ -91,6 +91,12 @@ body {
|
|||
color: #44403c;
|
||||
}
|
||||
|
||||
.site label:has(input[type='radio']),
|
||||
.site label:has(input[type='checkbox']),
|
||||
.survey-choice {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.site input,
|
||||
.site textarea,
|
||||
.site button,
|
||||
|
|
@ -187,6 +193,9 @@ a:focus-visible {
|
|||
.site-brand span {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
.user-brand span {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
.site main {
|
||||
|
|
@ -275,18 +284,23 @@ a:focus-visible {
|
|||
color: var(--brand);
|
||||
}
|
||||
|
||||
.site button {
|
||||
.site button,
|
||||
.site a.button {
|
||||
display: inline-block;
|
||||
padding: 0.7rem 1.15rem;
|
||||
border: 0;
|
||||
border-radius: 0.55rem;
|
||||
background: linear-gradient(135deg, var(--brand) 0%, #1578c8 100%);
|
||||
color: #f0fdfa;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.site button:hover:not(:disabled) {
|
||||
.site button:hover:not(:disabled),
|
||||
.site a.button:hover {
|
||||
background: linear-gradient(135deg, #0d6a63 0%, #1d4ed8 100%);
|
||||
color: #f0fdfa;
|
||||
}
|
||||
|
||||
.site button:disabled {
|
||||
|
|
@ -433,7 +447,80 @@ a:focus-visible {
|
|||
}
|
||||
|
||||
.error {
|
||||
color: #b91c1c;
|
||||
color: #b91c1c !important;
|
||||
}
|
||||
|
||||
.field-error {
|
||||
margin: 0.2rem 0 0.75rem;
|
||||
color: #b91c1c !important;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.field-tip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.05rem;
|
||||
height: 1.05rem;
|
||||
margin-left: 0.35rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: #f8fafc;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
cursor: help;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.field-tip:focus-visible {
|
||||
outline: 2px solid var(--brand);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
margin: 0.2rem 0 0.65rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.cheque-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin: 0.35rem 0 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.cheque-image {
|
||||
display: block;
|
||||
height: 4rem;
|
||||
margin: 0;
|
||||
padding: 0.35rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.5rem;
|
||||
background: #f8fafc;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.cheque-remove {
|
||||
padding: 0.3rem 0.65rem;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.cheque-status {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.agreement-page .admin-file:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.site main.wide {
|
||||
|
|
@ -791,3 +878,346 @@ a:focus-visible {
|
|||
clip: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.user-shell {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--page);
|
||||
}
|
||||
|
||||
.user-shell-loading {
|
||||
margin: 2rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.user-topbar {
|
||||
position: relative;
|
||||
z-index: 40;
|
||||
background: rgb(255 255 255 / 0.92);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
backdrop-filter: blur(16px);
|
||||
border-bottom: 3px solid var(--brand);
|
||||
}
|
||||
|
||||
.user-topbar-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
margin: 0 auto;
|
||||
padding: 0.75rem 1.5rem;
|
||||
}
|
||||
|
||||
.user-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
color: var(--brand-dark);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.user-brand img {
|
||||
height: 56px;
|
||||
width: auto;
|
||||
padding-right: 0.5rem;
|
||||
border-right: 1px dotted var(--brand);
|
||||
}
|
||||
|
||||
.user-menu {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.user-shell .user-menu-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
min-width: 2.75rem;
|
||||
padding: 0.45rem 0.65rem;
|
||||
border: 1px solid rgb(255 255 255 / 0.35);
|
||||
border-radius: 0.55rem;
|
||||
font: inherit;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.user-menu-icon {
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-menu-panel {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 0.35rem);
|
||||
z-index: 50;
|
||||
min-width: 14rem;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.6rem;
|
||||
background: #fff;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.user-menu-email {
|
||||
margin: 0 0 0.15rem !important;
|
||||
padding: 0.35rem 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.user-shell .user-menu-panel button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.55rem 0.65rem;
|
||||
border: 0;
|
||||
border-radius: 0.45rem;
|
||||
background: transparent;
|
||||
color: var(--brand-dark);
|
||||
font: inherit;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.user-shell .user-menu-panel button:hover:not(:disabled) {
|
||||
background: #f3f4f6;
|
||||
color: var(--brand-dark);
|
||||
}
|
||||
|
||||
.user-main {
|
||||
flex: 1;
|
||||
width: calc(100% - 3rem);
|
||||
/* Keep the same narrow card as .site main for agreement / survey / etc. */
|
||||
max-width: 40rem;
|
||||
margin: 1.5rem auto 2rem;
|
||||
padding: 1.5rem;
|
||||
box-sizing: border-box;
|
||||
background: rgb(255 255 255 / 0.96);
|
||||
border: 1px solid rgb(255 255 255 / 0.8);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.site main.user-main.wide {
|
||||
max-width: none;
|
||||
width: calc(100% - 3rem);
|
||||
}
|
||||
|
||||
.user-dashboard h1 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.user-step-list {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
margin: 1.25rem 0;
|
||||
}
|
||||
|
||||
.user-step {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.85rem 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.user-step.done {
|
||||
border-color: #99f6e4;
|
||||
background: #f0fdfa;
|
||||
}
|
||||
|
||||
.user-step span {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.user-next-hint {
|
||||
margin-bottom: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.user-detail-card {
|
||||
margin: 1.25rem 0;
|
||||
padding: 1rem 1.1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.user-detail-card h2 {
|
||||
margin: 0 0 0.85rem;
|
||||
font-size: 1.05rem;
|
||||
color: var(--brand-dark);
|
||||
}
|
||||
|
||||
.user-detail-empty {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.user-detail-grid {
|
||||
display: grid;
|
||||
gap: 0.65rem 1.25rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 40rem) {
|
||||
.user-detail-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.user-detail-grid dt {
|
||||
margin: 0;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.user-detail-grid dd {
|
||||
margin: 0.15rem 0 0;
|
||||
font-size: 0.95rem;
|
||||
color: var(--text);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.user-detail-links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem 1.25rem;
|
||||
margin-top: 1.5rem !important;
|
||||
}
|
||||
|
||||
.user-agreement-heading {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.65rem 0.85rem;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.user-agreement-heading h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.user-status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.user-status-approved {
|
||||
background: #dcfce7;
|
||||
color: #14532d;
|
||||
}
|
||||
|
||||
.user-status-submitted {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.user-status-rejected {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.user-status-draft {
|
||||
background: #e7e5e4;
|
||||
color: #44403c;
|
||||
}
|
||||
|
||||
.user-agreement-layout {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
margin: 1.25rem 0;
|
||||
}
|
||||
|
||||
@media (min-width: 64rem) {
|
||||
.user-agreement-layout {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
align-items: start;
|
||||
}
|
||||
}
|
||||
|
||||
.user-agreement-layout > .user-detail-card {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.user-agreement-preview .preview-frame {
|
||||
margin-bottom: 0;
|
||||
min-height: 70vh;
|
||||
}
|
||||
|
||||
.user-cheque-block {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.user-cheque-block h3 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.92rem;
|
||||
color: var(--brand-dark);
|
||||
}
|
||||
|
||||
.user-cheque-image {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 10rem;
|
||||
padding: 0.35rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.5rem;
|
||||
background: #f8fafc;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.consent-page h2 {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.consent-pis {
|
||||
max-height: max(16rem, calc(100svh - 28rem));
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.consent-checks {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
margin: 1rem 0 1.25rem;
|
||||
}
|
||||
|
||||
.consent-check {
|
||||
margin-bottom: 0;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.consent-fields {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 40.0625rem) {
|
||||
.consent-fields {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate, useOutletContext } from 'react-router-dom';
|
||||
import agreementText from '../../../files/docs/agreement.md?raw';
|
||||
import { api, getResumeToken, saveResumeToken } from '../api.js';
|
||||
import FlowSteps from '../FlowSteps.jsx';
|
||||
|
|
@ -55,13 +55,145 @@ export function upperValue(value) {
|
|||
return value.toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} message
|
||||
* @returns {string | null}
|
||||
*/
|
||||
export function fieldKeyForMessage(message) {
|
||||
const text = message.toLowerCase();
|
||||
|
||||
if (text.includes('registration number')) {
|
||||
return 'registration_no';
|
||||
}
|
||||
|
||||
if (text.includes('email')) {
|
||||
return 'email';
|
||||
}
|
||||
|
||||
if (text.includes('agreement date') || text.includes('choose the agreement date')) {
|
||||
return 'agreement_date';
|
||||
}
|
||||
|
||||
if (text.includes('full name')) {
|
||||
return 'full_name';
|
||||
}
|
||||
|
||||
if (text.includes('age')) {
|
||||
return 'age';
|
||||
}
|
||||
|
||||
if (text.includes('address')) {
|
||||
return 'address';
|
||||
}
|
||||
|
||||
if (text.includes('medical council')) {
|
||||
return 'medical_council';
|
||||
}
|
||||
|
||||
if (text.includes('gstin')) {
|
||||
return 'gstin';
|
||||
}
|
||||
|
||||
if (text.includes('pan')) {
|
||||
return 'pan';
|
||||
}
|
||||
|
||||
if (text.includes('bank name')) {
|
||||
return 'bank_name';
|
||||
}
|
||||
|
||||
if (text.includes('account holder')) {
|
||||
return 'account_holder';
|
||||
}
|
||||
|
||||
if (text.includes('account number')) {
|
||||
return 'account_number';
|
||||
}
|
||||
|
||||
if (text.includes('ifsc')) {
|
||||
return 'ifsc';
|
||||
}
|
||||
|
||||
if (
|
||||
text.includes('cheque') ||
|
||||
text.includes('png or jpeg') ||
|
||||
text.includes('500kb') ||
|
||||
text.includes('5mb')
|
||||
) {
|
||||
return 'cancelled_cheque';
|
||||
}
|
||||
|
||||
if (text.includes('accept the agreement')) {
|
||||
return 'accepted';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const BANK_TIPS = {
|
||||
bank_name: 'Enter the full bank name as printed on your account or cheque',
|
||||
account_holder: 'Name as per bank account only',
|
||||
account_number: 'Account number without spaces or hyphens',
|
||||
ifsc: '11-character IFSC code from your cheque book or passbook',
|
||||
cancelled_cheque: 'Optional. Upload a clear PNG or JPEG of a cancelled cheque (max 5MB)',
|
||||
};
|
||||
|
||||
const CHEQUE_MAX_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* @param {{ message: string }} props
|
||||
*/
|
||||
function FieldError({ message }) {
|
||||
if (!message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<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() {
|
||||
const navigate = useNavigate();
|
||||
const me = useOutletContext();
|
||||
const sessionEmail = me?.user?.email || '';
|
||||
const [application, setApplication] = useState(null);
|
||||
const [loadError, setLoadError] = useState('');
|
||||
const [saveError, setSaveError] = useState('');
|
||||
/** @type {[Record<string, string>, Function]} */
|
||||
const [fieldErrors, setFieldErrors] = useState({});
|
||||
const [status, setStatus] = useState('');
|
||||
const [ready, setReady] = useState(false);
|
||||
const [chequeUrl, setChequeUrl] = useState('');
|
||||
const [chequeBusy, setChequeBusy] = useState(false);
|
||||
const [chequeStatus, setChequeStatus] = useState('');
|
||||
const chequeInputRef = useRef(/** @type {HTMLInputElement | null} */ (null));
|
||||
const {
|
||||
register,
|
||||
reset,
|
||||
|
|
@ -76,9 +208,40 @@ export default function AgreementPage() {
|
|||
const locked = readOnly || needsEdit;
|
||||
const lockedRef = useRef(locked);
|
||||
lockedRef.current = locked;
|
||||
const hasSession = Boolean(me?.user?.id);
|
||||
|
||||
/**
|
||||
* @param {string} field
|
||||
*/
|
||||
function clearFieldError(field) {
|
||||
setFieldErrors((current) => {
|
||||
if (!current[field]) {
|
||||
return current;
|
||||
}
|
||||
|
||||
const next = { ...current };
|
||||
delete next[field];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} message
|
||||
*/
|
||||
function applyErrorMessage(message) {
|
||||
const field = fieldKeyForMessage(message);
|
||||
|
||||
if (field) {
|
||||
setFieldErrors((current) => ({ ...current, [field]: message }));
|
||||
setSaveError('');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaveError(message);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!getResumeToken()) {
|
||||
if (!hasSession && !getResumeToken()) {
|
||||
reset({
|
||||
...EMPTY,
|
||||
agreement_date: today(),
|
||||
|
|
@ -96,9 +259,11 @@ export default function AgreementPage() {
|
|||
}
|
||||
|
||||
setApplication(loaded);
|
||||
setChequeUrl(loaded.cancelledChequeUrl || '');
|
||||
reset({
|
||||
...EMPTY,
|
||||
...loaded,
|
||||
email: loaded.email || sessionEmail,
|
||||
agreement_date: loaded.agreement_date || today(),
|
||||
age: loaded.age ?? '',
|
||||
gstin: loaded.gstin ?? '',
|
||||
|
|
@ -115,7 +280,7 @@ export default function AgreementPage() {
|
|||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [reset]);
|
||||
}, [hasSession, reset, sessionEmail]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready) {
|
||||
|
|
@ -123,7 +288,7 @@ export default function AgreementPage() {
|
|||
}
|
||||
|
||||
const timer = setInterval(() => {
|
||||
if (lockedRef.current || !getResumeToken()) {
|
||||
if (lockedRef.current || (!hasSession && !getResumeToken())) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -134,15 +299,21 @@ export default function AgreementPage() {
|
|||
}
|
||||
|
||||
api('/api/applications/me', { method: 'PUT', body: payload })
|
||||
.then(() => setStatus('Draft saved'))
|
||||
.catch((error) => setSaveError(error.message));
|
||||
.then(() => {
|
||||
setStatus('Draft saved');
|
||||
setSaveError('');
|
||||
})
|
||||
.catch((error) => {
|
||||
setStatus('');
|
||||
applyErrorMessage(error.message);
|
||||
});
|
||||
}, 10000);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [dirtyFields, getValues, ready]);
|
||||
}, [dirtyFields, getValues, hasSession, ready]);
|
||||
|
||||
async function ensureDraft() {
|
||||
if (getResumeToken()) {
|
||||
if (hasSession || getResumeToken()) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -166,6 +337,7 @@ export default function AgreementPage() {
|
|||
|
||||
async function continueToReview() {
|
||||
setSaveError('');
|
||||
setFieldErrors({});
|
||||
const values = getValues();
|
||||
const parsed = applicationSchema.safeParse({
|
||||
...values,
|
||||
|
|
@ -174,7 +346,22 @@ export default function AgreementPage() {
|
|||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
setSaveError(parsed.error.issues[0].message);
|
||||
/** @type {Record<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;
|
||||
}
|
||||
|
||||
|
|
@ -186,7 +373,7 @@ export default function AgreementPage() {
|
|||
await api('/api/applications/me?finalize=true', { method: 'PUT', body: fields });
|
||||
navigate('/review');
|
||||
} catch (error) {
|
||||
setSaveError(error.message);
|
||||
applyErrorMessage(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -196,25 +383,97 @@ export default function AgreementPage() {
|
|||
try {
|
||||
const updated = await api('/api/applications/me/edit', { method: 'POST' });
|
||||
setApplication(updated);
|
||||
setChequeUrl(updated.cancelledChequeUrl || '');
|
||||
setStatus('');
|
||||
} catch (error) {
|
||||
setSaveError(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('react').ChangeEvent<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) {
|
||||
return (
|
||||
<main>
|
||||
<section className="agreement-page">
|
||||
<h1>Agreement</h1>
|
||||
<p className="error" role="alert">
|
||||
{loadError}
|
||||
</p>
|
||||
</main>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<section className="agreement-page">
|
||||
<h1>Agreement</h1>
|
||||
<p className="page-lead">Read the terms, then enter your details to continue.</p>
|
||||
<FlowSteps current={readOnly ? 'done' : accepted ? 'details' : 'read'} />
|
||||
|
|
@ -252,94 +511,229 @@ export default function AgreementPage() {
|
|||
</label>
|
||||
{accepted ? (
|
||||
<>
|
||||
<fieldset disabled={locked}>
|
||||
<legend>Agreement date</legend>
|
||||
<label htmlFor="agreement_date">Date</label>
|
||||
<input
|
||||
id="agreement_date"
|
||||
type="date"
|
||||
readOnly={locked}
|
||||
{...register('agreement_date')}
|
||||
/>
|
||||
</fieldset>
|
||||
<fieldset disabled={locked}>
|
||||
<legend>Physician details</legend>
|
||||
<label htmlFor="email">Email</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
readOnly={locked}
|
||||
{...register('email')}
|
||||
/>
|
||||
<label htmlFor="full_name">Full name</label>
|
||||
<input id="full_name" autoComplete="name" readOnly={locked} {...register('full_name')} />
|
||||
<label htmlFor="age">Age</label>
|
||||
<input id="age" type="number" min="21" max="100" readOnly={locked} {...register('age')} />
|
||||
<label htmlFor="address">Address</label>
|
||||
<textarea id="address" readOnly={locked} {...register('address')} />
|
||||
<label htmlFor="registration_no">Registration number</label>
|
||||
<input id="registration_no" readOnly={locked} {...register('registration_no')} />
|
||||
<label htmlFor="medical_council">Medical council</label>
|
||||
<input id="medical_council" readOnly={locked} {...register('medical_council')} />
|
||||
<label htmlFor="pan">PAN</label>
|
||||
<input
|
||||
id="pan"
|
||||
readOnly={locked}
|
||||
{...register('pan')}
|
||||
onChange={(event) =>
|
||||
setValue('pan', upperValue(event.target.value), { shouldDirty: true })
|
||||
}
|
||||
/>
|
||||
<label htmlFor="gstin">GSTIN (optional)</label>
|
||||
<input
|
||||
id="gstin"
|
||||
readOnly={locked}
|
||||
{...register('gstin')}
|
||||
onChange={(event) =>
|
||||
setValue('gstin', upperValue(event.target.value), { shouldDirty: true })
|
||||
}
|
||||
/>
|
||||
</fieldset>
|
||||
<fieldset disabled={locked}>
|
||||
<legend>Bank details</legend>
|
||||
<label htmlFor="bank_name">Bank name</label>
|
||||
<input id="bank_name" readOnly={locked} {...register('bank_name')} />
|
||||
<label htmlFor="account_holder">Account holder name</label>
|
||||
<input id="account_holder" readOnly={locked} {...register('account_holder')} />
|
||||
<label htmlFor="account_number">Account number</label>
|
||||
<input
|
||||
id="account_number"
|
||||
inputMode="numeric"
|
||||
readOnly={locked}
|
||||
{...register('account_number')}
|
||||
/>
|
||||
<label htmlFor="ifsc">IFSC</label>
|
||||
<input
|
||||
id="ifsc"
|
||||
readOnly={locked}
|
||||
{...register('ifsc')}
|
||||
onChange={(event) =>
|
||||
setValue('ifsc', upperValue(event.target.value), { shouldDirty: true })
|
||||
}
|
||||
/>
|
||||
</fieldset>
|
||||
{saveError ? (
|
||||
<p className="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>
|
||||
)}
|
||||
<fieldset disabled={locked}>
|
||||
<legend>Agreement date</legend>
|
||||
<label htmlFor="agreement_date">Date</label>
|
||||
<input
|
||||
id="agreement_date"
|
||||
type="date"
|
||||
readOnly={locked}
|
||||
{...register('agreement_date', {
|
||||
onChange: () => clearFieldError('agreement_date'),
|
||||
})}
|
||||
/>
|
||||
<FieldError message={fieldErrors.agreement_date} />
|
||||
</fieldset>
|
||||
<fieldset disabled={locked}>
|
||||
<legend>Physician details</legend>
|
||||
<label htmlFor="email">Email</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
readOnly={locked || hasSession}
|
||||
{...register('email', { onChange: () => clearFieldError('email') })}
|
||||
/>
|
||||
<FieldError message={fieldErrors.email} />
|
||||
<label htmlFor="full_name">Full name</label>
|
||||
<input
|
||||
id="full_name"
|
||||
autoComplete="name"
|
||||
readOnly={locked}
|
||||
{...register('full_name', { onChange: () => clearFieldError('full_name') })}
|
||||
/>
|
||||
<FieldError message={fieldErrors.full_name} />
|
||||
<label htmlFor="age">Age</label>
|
||||
<input
|
||||
id="age"
|
||||
type="number"
|
||||
min="21"
|
||||
max="100"
|
||||
readOnly={locked}
|
||||
{...register('age', { onChange: () => clearFieldError('age') })}
|
||||
/>
|
||||
<FieldError message={fieldErrors.age} />
|
||||
<label htmlFor="address">Address</label>
|
||||
<textarea
|
||||
id="address"
|
||||
readOnly={locked}
|
||||
{...register('address', { onChange: () => clearFieldError('address') })}
|
||||
/>
|
||||
<FieldError message={fieldErrors.address} />
|
||||
<label htmlFor="registration_no">Registration number</label>
|
||||
<input
|
||||
id="registration_no"
|
||||
readOnly={locked}
|
||||
{...register('registration_no', {
|
||||
onChange: () => clearFieldError('registration_no'),
|
||||
})}
|
||||
/>
|
||||
<FieldError message={fieldErrors.registration_no} />
|
||||
<label htmlFor="medical_council">Medical council</label>
|
||||
<input
|
||||
id="medical_council"
|
||||
readOnly={locked}
|
||||
{...register('medical_council', {
|
||||
onChange: () => clearFieldError('medical_council'),
|
||||
})}
|
||||
/>
|
||||
<FieldError message={fieldErrors.medical_council} />
|
||||
<label htmlFor="pan">PAN</label>
|
||||
<input
|
||||
id="pan"
|
||||
readOnly={locked}
|
||||
{...register('pan')}
|
||||
onChange={(event) => {
|
||||
clearFieldError('pan');
|
||||
setValue('pan', upperValue(event.target.value), { shouldDirty: true });
|
||||
}}
|
||||
/>
|
||||
<FieldError message={fieldErrors.pan} />
|
||||
<label htmlFor="gstin">GSTIN (optional)</label>
|
||||
<input
|
||||
id="gstin"
|
||||
readOnly={locked}
|
||||
{...register('gstin')}
|
||||
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}
|
||||
</form>
|
||||
</main>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
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 () => {
|
||||
sessionStorage.removeItem('resumeToken');
|
||||
render(
|
||||
|
|
@ -56,4 +62,60 @@ describe('agreement page', () => {
|
|||
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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api, getResumeToken } from '../api.js';
|
||||
import { api } from '../api.js';
|
||||
import FlowSteps from '../FlowSteps.jsx';
|
||||
|
||||
export default function ReviewPage() {
|
||||
|
|
@ -12,11 +12,6 @@ export default function ReviewPage() {
|
|||
const locked = application?.status === 'SUBMITTED' || application?.status === 'APPROVED';
|
||||
|
||||
useEffect(() => {
|
||||
if (!getResumeToken()) {
|
||||
setError('Open the private link from your email to continue.');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
api('/api/applications/me')
|
||||
|
|
@ -61,7 +56,7 @@ export default function ReviewPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<main className="wide">
|
||||
<section className="agreement-page wide">
|
||||
<h1>Review agreement</h1>
|
||||
<p className="page-lead">Check the preview before you sign.</p>
|
||||
<FlowSteps current={locked ? 'done' : 'review'} />
|
||||
|
|
@ -95,6 +90,6 @@ export default function ReviewPage() {
|
|||
</button>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,30 @@
|
|||
import { useEffect, useRef, useState } from 'react';
|
||||
import SignatureCanvas from 'react-signature-canvas';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api, getResumeToken } from '../api.js';
|
||||
import { api } from '../api.js';
|
||||
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() {
|
||||
const navigate = useNavigate();
|
||||
const padRef = useRef(null);
|
||||
|
|
@ -14,11 +35,6 @@ export default function SignPage() {
|
|||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!getResumeToken()) {
|
||||
setError('Open the private link from your email to continue.');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
api('/api/applications/me')
|
||||
|
|
@ -60,7 +76,7 @@ export default function SignPage() {
|
|||
return;
|
||||
}
|
||||
|
||||
body = { type: 'DRAWN', image: padRef.current.toDataURL('image/png'), consent: true };
|
||||
body = { type: 'DRAWN', image: drawnSignatureDataUrl(padRef.current), consent: true };
|
||||
} else {
|
||||
if (!typedName.trim()) {
|
||||
setError('Type your signature.');
|
||||
|
|
@ -73,8 +89,14 @@ export default function SignPage() {
|
|||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
await api('/api/applications/me/sign', { method: 'POST', body });
|
||||
navigate('/submitted');
|
||||
const result = await api('/api/applications/me/sign', { method: 'POST', body });
|
||||
|
||||
if (typeof result.surveyToken === 'string' && result.surveyToken) {
|
||||
navigate(`/survey/${result.surveyToken}`, { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
navigate('/submitted', { replace: true });
|
||||
} catch (signError) {
|
||||
setError(signError.message);
|
||||
setSubmitting(false);
|
||||
|
|
@ -82,7 +104,7 @@ export default function SignPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<section className="agreement-page">
|
||||
<h1>Sign the agreement</h1>
|
||||
<p className="page-lead">Draw your signature, or type it instead.</p>
|
||||
<FlowSteps current="sign" />
|
||||
|
|
@ -147,6 +169,6 @@ export default function SignPage() {
|
|||
{submitting ? 'Submitting…' : 'Submit signature'}
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,15 @@
|
|||
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 { userApi } from '../userApi.js';
|
||||
|
||||
export default function SubmittedPage() {
|
||||
const [application, setApplication] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
const [surveyBusy, setSurveyBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!getResumeToken()) {
|
||||
setError('Open the private link from your email to continue.');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
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 (
|
||||
<main>
|
||||
<h1>Agreement submitted</h1>
|
||||
<p className="page-lead">Your signed agreement has been sent for review.</p>
|
||||
<section className="agreement-page">
|
||||
<h1>{approved ? 'Agreement approved' : 'Agreement submitted'}</h1>
|
||||
<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" />
|
||||
{error ? (
|
||||
<p className="error" role="alert">
|
||||
|
|
@ -45,8 +66,10 @@ export default function SubmittedPage() {
|
|||
) : null}
|
||||
<ol className="timeline">
|
||||
<li className="done">Submitted</li>
|
||||
<li className={decided ? 'done' : 'current'}>Under review</li>
|
||||
<li className={decided ? 'done' : undefined}>Decision emailed</li>
|
||||
<li className={approved || rejected ? 'done' : 'current'}>
|
||||
{approved ? 'Approved' : rejected ? 'Rejected' : 'Under review'}
|
||||
</li>
|
||||
<li className={approved ? 'done' : undefined}>Survey available</li>
|
||||
</ol>
|
||||
{application?.signedPdfUrl ? (
|
||||
<p>
|
||||
|
|
@ -55,6 +78,18 @@ export default function SubmittedPage() {
|
|||
</a>
|
||||
</p>
|
||||
) : 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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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 { surveyApi } from '../surveyApi.js';
|
||||
|
||||
|
|
@ -483,6 +483,11 @@ export default function SurveyPage() {
|
|||
<main className="wide">
|
||||
<h1>Already submitted</h1>
|
||||
<p>{message}</p>
|
||||
<p className="actions">
|
||||
<Link to="/dashboard" className="button">
|
||||
Go to dashboard
|
||||
</Link>
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -492,6 +497,11 @@ export default function SurveyPage() {
|
|||
<main className="wide">
|
||||
<h1>Thank you</h1>
|
||||
<p>Your survey has been submitted.</p>
|
||||
<p className="actions">
|
||||
<Link to="/dashboard" className="button">
|
||||
Go to dashboard
|
||||
</Link>
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import react from '@vitejs/plugin-react';
|
|||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const apiProxyTarget = process.env.API_PROXY_TARGET || 'http://localhost:4000';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
|
|
@ -14,7 +15,11 @@ export default defineConfig({
|
|||
allow: [root],
|
||||
},
|
||||
proxy: {
|
||||
'/api': 'http://localhost:4000',
|
||||
'/api': {
|
||||
target: apiProxyTarget,
|
||||
changeOrigin: true,
|
||||
secure: apiProxyTarget.startsWith('https'),
|
||||
},
|
||||
},
|
||||
},
|
||||
test: {
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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: ______
|
||||
|
|
@ -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.
|
||||
|
|
@ -2279,9 +2279,6 @@
|
|||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2452,9 +2449,6 @@
|
|||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2469,9 +2463,6 @@
|
|||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2486,9 +2477,6 @@
|
|||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2503,9 +2491,6 @@
|
|||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2520,9 +2505,6 @@
|
|||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2537,9 +2519,6 @@
|
|||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2554,9 +2533,6 @@
|
|||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2571,9 +2547,6 @@
|
|||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2588,9 +2561,6 @@
|
|||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2605,9 +2575,6 @@
|
|||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2622,9 +2589,6 @@
|
|||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2639,9 +2603,6 @@
|
|||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2656,9 +2617,6 @@
|
|||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -3025,9 +2983,6 @@
|
|||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -3045,9 +3000,6 @@
|
|||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -3065,9 +3017,6 @@
|
|||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -3085,9 +3034,6 @@
|
|||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -7804,9 +7750,6 @@
|
|||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -7828,9 +7771,6 @@
|
|||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -7852,9 +7792,6 @@
|
|||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -7876,9 +7813,6 @@
|
|||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
],
|
||||
"scripts": {
|
||||
"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 .",
|
||||
"migrate": "npm run migrate -w backend",
|
||||
"seed:admin": "npm run seed:admin -w backend",
|
||||
|
|
|
|||
Loading…
Reference in New Issue