###---- changes as per feedback on 22 Oct 2026

- Replace “Adherence Pack Study” with “Extended-Dose Pack Study” wherever applicable across the platform and related documents.
- barriers: Need a separate column at backend for each option - added seperate columns for each option in Survey Export in Adminpanel.
- benefit: Need a separate column at backend for each option - added seperate columns for each option in Survey Export in Adminpanel.
- Also added one more report in adminapel for exporting applications with consent data.

-- Survey questions:
- practice_setting: change type to multiple choice
- state: create a drop down list for state
- Added more validation for following fields : as user can not enter negative and more than 100 values in the field.
  - asthma_formoterol_pct: Could you please restrict the percentage field to a valid range of 0–100? Becasue although a validation check appears to be applied, the field currently accepts negative values and values greater than 100.
  - copd_formoterol_pct: same as above
  - pack_split: Please add an appropriate range check (0-100) to prevent entry of values outside this range.
  - adherence_estimate: Please add an appropriate range check (0-100) to prevent entry of values outside this range.
This commit is contained in:
Amey Bhagwat 2026-10-26 10:13:16 +05:30
parent 29f879c5d1
commit 125e679019
22 changed files with 1126 additions and 111 deletions

View File

@ -2,6 +2,7 @@ import Boom from '@hapi/boom';
import { z } from 'zod';
import { auditFromRequest } from '../middleware/audit.js';
import { requireAdmin } from '../middleware/requireAdmin.js';
import { exportApplicationsWorkbook } from '../services/applicationsExport.js';
import { exportWorkbook } from '../services/surveyExport.js';
import { getReminderSettings, saveReminderSettings } from '../services/settings.js';
import {
@ -25,22 +26,23 @@ const pre = [requireAdmin];
const blankToUndefined = (value) => (value === '' || value === undefined ? undefined : value);
const optionalDate = z.preprocess(
blankToUndefined,
z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.optional(),
);
const exportQuerySchema = z.object({
status: z.preprocess(blankToUndefined, z.enum(['COMPLETED']).default('COMPLETED')),
from: z.preprocess(
blankToUndefined,
z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.optional(),
),
to: z.preprocess(
blankToUndefined,
z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.optional(),
),
from: optionalDate,
to: optionalDate,
});
const applicationsExportQuerySchema = z.object({
from: optionalDate,
to: optionalDate,
});
const reminderSchema = z.object({
@ -243,6 +245,32 @@ export const adminRoutes = {
},
});
server.route({
method: 'GET',
path: '/api/admin/applications/export',
options: { pre },
async handler(request, h) {
const parsed = applicationsExportQuerySchema.safeParse(request.query);
if (!parsed.success) {
throw Boom.badRequest('Use dates as YYYY-MM-DD');
}
const stream = await exportApplicationsWorkbook(parsed.data);
await auditFromRequest(request, {
actorType: 'ADMIN',
actorId: request.pre.admin.id,
action: 'APPLICATIONS_EXPORTED',
entityType: 'applications',
metadata: parsed.data,
});
return h
.response(stream)
.type('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
.header('content-disposition', 'attachment; filename="applications-consent.xlsx"');
},
});
server.route({
method: 'GET',
path: '/api/admin/audit-log',

View File

@ -1,17 +1,55 @@
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',
/** Must match `frontend/src/consentQuestions.js`; a test checks both lists. */
export const CONSENT_STATEMENTS = [
{
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 const CONSENT_ANSWER_KEYS = CONSENT_STATEMENTS.map((statement) => statement.id);
const requiredTrue = z.literal(true, {
errorMap: () => ({ message: 'This confirmation is required' }),
});

View File

@ -0,0 +1,221 @@
import { PassThrough } from 'node:stream';
import ExcelJS from 'exceljs';
import { db } from '../db/knex.js';
import { CONSENT_STATEMENTS } from '../schemas/consent.js';
import { decryptField } from './fieldCrypto.js';
const STATUS_LABELS = {
DRAFT: 'Draft',
SUBMITTED: 'Submitted',
APPROVED: 'Approved',
REJECTED: 'Rejected',
};
const SURVEY_LABELS = {
IN_PROGRESS: 'In progress',
COMPLETED: 'Completed',
};
/**
* `text: true` keeps Excel from turning account numbers and similar ids into
* numbers (scientific notation, dropped leading zeros).
* @type {{ key: string, header: string, width: number, text?: boolean }[]}
*/
const BASE_COLUMNS = [
{ key: 'applicationId', header: 'Application id', width: 14 },
{ key: 'status', header: 'Status', width: 14 },
{ key: 'email', header: 'Email', width: 30 },
{ key: 'fullName', header: 'Full name', width: 26 },
{ key: 'age', header: 'Age', width: 8 },
{ key: 'address', header: 'Address', width: 40 },
{ key: 'registrationNo', header: 'Registration no', width: 18, text: true },
{ key: 'medicalCouncil', header: 'Medical council', width: 26 },
{ key: 'pan', header: 'PAN', width: 14, text: true },
{ key: 'gstin', header: 'GSTIN', width: 18, text: true },
{ key: 'bankName', header: 'Bank name', width: 24 },
{ key: 'accountHolder', header: 'Account holder', width: 26 },
{ key: 'accountNumber', header: 'Account number', width: 22, text: true },
{ key: 'ifsc', header: 'IFSC', width: 14, text: true },
{ key: 'createdAt', header: 'Created at', width: 20 },
{ key: 'agreementDate', header: 'Agreement date', width: 14 },
{ key: 'submittedAt', header: 'Submitted at', width: 20 },
{ key: 'decidedAt', header: 'Decided at', width: 20 },
{ key: 'rejectionReason', header: 'Rejection reason', width: 30 },
{ key: 'chequeUploaded', header: 'Cheque uploaded', width: 14 },
{ key: 'signed', header: 'Signed', width: 10 },
{ key: 'finalPdf', header: 'Final PDF available', width: 16 },
{ key: 'consentName', header: 'Consent participant name', width: 26 },
{ key: 'consentDate', header: 'Consent date', width: 14 },
{ key: 'consentSubmittedAt', header: 'Consent submitted at', width: 20 },
];
const CONSENT_COLUMNS = CONSENT_STATEMENTS.map((statement) => ({
key: `consent_${statement.id}`,
header: statement.label,
width: 36,
}));
const SURVEY_COLUMNS = [
{ key: 'surveyStatus', header: 'Survey status', width: 14 },
{ key: 'surveyCompletedAt', header: 'Survey completed at', width: 20 },
];
export const APPLICATION_EXPORT_COLUMNS = [...BASE_COLUMNS, ...CONSENT_COLUMNS, ...SURVEY_COLUMNS];
/**
* @param {unknown} value
*/
function parseJson(value) {
if (typeof value === 'string') {
return JSON.parse(value);
}
return value ?? null;
}
/**
* @param {unknown} value
*/
function text(value) {
return value === null || value === undefined ? '' : String(value);
}
/**
* @param {unknown} value
*/
function yesNo(value) {
return value ? 'Yes' : 'No';
}
/**
* @param {Record<string, any>} record
* @param {Set<number>} signedIds
*/
export function applicationExportRow(record, signedIds) {
const consentAnswers = record.consentId ? (parseJson(record.consentAnswers) ?? {}) : null;
/** @type {Record<string, string | number>} */
const row = {
applicationId: record.id,
status: STATUS_LABELS[record.status] ?? text(record.status),
email: text(record.email),
fullName: text(record.full_name),
age: typeof record.age === 'number' ? record.age : text(record.age),
address: text(record.address),
registrationNo: text(record.registration_no),
medicalCouncil: text(record.medical_council),
pan: text(decryptField(record.pan)),
gstin: text(record.gstin),
bankName: text(record.bank_name),
accountHolder: text(record.account_holder),
accountNumber: text(decryptField(record.account_number)),
ifsc: text(record.ifsc),
createdAt: text(record.created_at),
agreementDate: text(record.agreement_date).slice(0, 10),
submittedAt: text(record.submitted_at),
decidedAt: text(record.decided_at),
rejectionReason: text(record.rejection_reason),
chequeUploaded: yesNo(record.cancelled_cheque_key),
signed: yesNo(signedIds.has(record.id)),
finalPdf: yesNo(record.final_pdf_key),
consentName: text(record.consentName),
consentDate: text(record.consentDate).slice(0, 10),
consentSubmittedAt: text(record.consentSubmittedAt),
surveyStatus: SURVEY_LABELS[record.surveyStatus] ?? 'Not started',
surveyCompletedAt: text(record.surveyCompletedAt),
};
for (const statement of CONSENT_STATEMENTS) {
row[`consent_${statement.id}`] = consentAnswers
? consentAnswers[statement.id] === true
? 'Yes'
: 'No'
: '';
}
return row;
}
/**
* Decrypts PAN and account numbers before streaming starts, so a decryption
* failure surfaces as an error response instead of a truncated file.
* @param {{ from?: string, to?: string }} query
*/
export async function loadApplicationsExport(query) {
let request = db('applications')
.leftJoin('consents', 'consents.application_id', 'applications.id')
.leftJoin('survey_responses', 'survey_responses.application_id', 'applications.id')
.select(
'applications.*',
'consents.id as consentId',
'consents.participant_name as consentName',
'consents.consent_date as consentDate',
'consents.submitted_at as consentSubmittedAt',
'consents.answers as consentAnswers',
'survey_responses.status as surveyStatus',
'survey_responses.completed_at as surveyCompletedAt',
)
.orderBy('applications.created_at', 'asc')
.orderBy('applications.id', 'asc');
if (query.from) {
request = request.where('applications.created_at', '>=', `${query.from} 00:00:00`);
}
if (query.to) {
request = request.where('applications.created_at', '<=', `${query.to} 23:59:59`);
}
const records = await request;
const ids = records.map((record) => record.id);
const signed = ids.length
? await db('signatures')
.whereIn('application_id', ids)
.whereNull('voided_at')
.distinct('application_id')
: [];
const signedIds = new Set(signed.map((row) => row.application_id));
return records.map((record) => applicationExportRow(record, signedIds));
}
/**
* @param {import('node:stream').Writable} stream
* @param {Record<string, string | number>[]} rows
*/
export async function writeApplicationsWorkbook(stream, rows) {
const workbook = new ExcelJS.stream.xlsx.WorkbookWriter({ stream, useStyles: true });
const sheet = workbook.addWorksheet('Applications', {
views: [{ state: 'frozen', ySplit: 1 }],
});
sheet.columns = APPLICATION_EXPORT_COLUMNS.map((column) => ({
header: column.header,
key: column.key,
width: column.width,
style: column.text ? { numFmt: '@' } : undefined,
}));
const header = sheet.getRow(1);
header.font = { bold: true };
header.alignment = { vertical: 'middle', wrapText: true };
for (const row of rows) {
sheet.addRow(row).commit();
}
sheet.commit();
await workbook.commit();
}
/**
* @param {{ from?: string, to?: string }} query
*/
export async function exportApplicationsWorkbook(query) {
const rows = await loadApplicationsExport(query);
const stream = new PassThrough();
const writing = writeApplicationsWorkbook(stream, rows).catch((error) => {
stream.destroy(error);
});
stream.on('error', () => {
writing.catch(() => {});
});
return stream;
}

View File

@ -32,7 +32,7 @@ function layout(content) {
<h1 style="font-size:24px;font-weight:600;margin:0 0 16px;">${escapeHtml(content.heading)}</h1>
${paragraphs}
${action}
<p style="margin:32px 0 0;font-size:13px;color:#57534e;">Adherence Pack Study</p>
<p style="margin:32px 0 0;font-size:13px;color:#57534e;">Extended-Dose Pack Study</p>
</div>
</body>
</html>`;
@ -63,7 +63,7 @@ export function renderEmail(name, data) {
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.',
'Use this one-time code to sign in to the Extended-Dose Pack Study. It expires in 10 minutes.',
data.otp || '',
'If you did not request this code, you can ignore this email.',
],
@ -119,7 +119,7 @@ export function renderEmail(name, data) {
heading: 'Continue your agreement',
paragraphs: [
`${physician}, your consultation agreement is still a draft.`,
'Use the private link below to continue. This is an operational email about the Adherence Pack Study.',
'Use the private link below to continue. This is an operational email about the Extended-Dose Pack Study.',
],
action: { href: data.url, label: 'Continue your agreement' },
});
@ -131,7 +131,7 @@ export function renderEmail(name, data) {
heading: 'Your survey is waiting',
paragraphs: [
`${physician}, your consultation agreement is approved and the survey is not yet submitted.`,
'The link below works for one submission. This is an operational email about the Adherence Pack Study.',
'The link below works for one submission. This is an operational email about the Extended-Dose Pack Study.',
],
action: { href: data.url, label: 'Open the survey' },
});

View File

@ -2,7 +2,7 @@ import Boom from '@hapi/boom';
import { db } from '../db/knex.js';
import { recordAudit } from './audit.js';
import { generateToken, hashToken } from './tokens.js';
import { knownAnswers, validateAnswers } from '../survey/validate.js';
import { knownAnswers, normalizeAnswers, validateAnswers } from '../survey/validate.js';
import { stripHtmlDeep } from './sanitize.js';
const INVALID = 'This survey link is invalid or has already been used';
@ -67,7 +67,7 @@ async function definitionFor(connection, applicationId) {
*/
function present(definitionRow, response, application) {
const definition = parseJson(definitionRow.definition);
const answers = parseJson(response?.answers) ?? {};
const answers = normalizeAnswers(definition, parseJson(response?.answers) ?? {});
if (typeof answers.name !== 'string' || !answers.name.trim()) {
if (application.full_name) {

View File

@ -1,9 +1,26 @@
import { PassThrough } from 'node:stream';
import ExcelJS from 'exceljs';
import { db } from '../db/knex.js';
import { normalizeAnswers } from '../survey/validate.js';
const SKIPPED = new Set(['help', 'section_header']);
const FIXED = ['Application id', 'Physician name', 'Registration no', 'Completion date'];
/** Keys are prefixed so they cannot collide with question ids such as `name`. */
const FIXED = [
{ key: '_applicationId', header: 'Application id' },
{ key: '_name', header: 'Physician name' },
{ key: '_registrationNo', header: 'Registration no' },
{ key: '_completedAt', header: 'Completion date' },
];
/** Multi-choice questions exported as one "Yes" column per option. */
const SPLIT_QUESTIONS = new Set(['barriers', 'benefit']);
/**
* @param {Record<string, any>} question
*/
function isSplit(question) {
return question.type === 'multi_choice' && SPLIT_QUESTIONS.has(question.id);
}
/**
* @param {unknown} value
@ -35,7 +52,7 @@ function gridPrefix(questionId) {
* @param {{ sections: { title: string, questions: Record<string, any>[] }[] }} definition
*/
export function answerColumns(definition) {
/** @type {{ key: string, header: string }[]} */
/** @type {{ key: string, header: string, group?: string, label?: string, context?: string }[]} */
const columns = [];
/** @type {Map<string, number>} */
const labelCounts = new Map();
@ -76,6 +93,21 @@ export function answerColumns(definition) {
continue;
}
if (isSplit(question)) {
const group = `${context}: ${question.label}`;
for (const option of question.options) {
columns.push({
key: `${question.id}.${option.id}`,
header: `${group} - ${option.label}`,
group,
label: option.label,
});
}
continue;
}
labelCounts.set(question.label, (labelCounts.get(question.label) ?? 0) + 1);
columns.push({
key: question.id,
@ -87,6 +119,10 @@ export function answerColumns(definition) {
}
return columns.map((column) => {
if (column.group) {
return { key: column.key, header: column.header, group: column.group, label: column.label };
}
if (!column.label || labelCounts.get(column.label) === 1) {
return { key: column.key, header: column.header };
}
@ -180,6 +216,23 @@ export function answerValue(definition, answers, key) {
return typeof value === 'string' ? value : '';
}
if (isSplit(question) && key.startsWith(`${question.id}.`)) {
const optionId = key.slice(question.id.length + 1);
const value = answers?.[question.id];
const choices =
value && typeof value === 'object' && Array.isArray(value.choices) ? value.choices : [];
if (!question.options?.some((option) => option.id === optionId)) {
continue;
}
if (!choices.includes(optionId)) {
return '';
}
return optionId === 'other' ? textOf(value.other) || 'Yes' : 'Yes';
}
if (question.type === 'grid_percentage' || question.type === 'grid_number') {
for (const row of question.rows) {
for (const column of question.columns) {
@ -216,6 +269,7 @@ export function answerValue(definition, answers, key) {
export function summaryRows(columns, rows, definition) {
/** @type {{ kind: string, field: string, value: number }[]} */
const summary = [];
const counted = new Set();
for (const column of columns) {
const question = questionFor(definition, column.key) ?? findQuestion(rows, column.key);
@ -225,6 +279,11 @@ export function summaryRows(columns, rows, definition) {
}
if (question.type === 'single_choice' || question.type === 'multi_choice') {
if (counted.has(question.id)) {
continue;
}
counted.add(question.id);
for (const option of question.options ?? []) {
const count = rows.filter((row) => selected(question, row.answers, option.id)).length;
summary.push({ kind: 'Count', field: `${question.label}: ${option.label}`, value: count });
@ -330,6 +389,54 @@ export function questionRows(definition) {
return rows;
}
/**
* Row 1 holds the question heading (merged across a split question's option
* columns); row 2 holds each option label. Other columns span both rows.
* @param {any} sheet
* @param {{ key: string, header: string, group?: string, label?: string }[]} columns
*/
function writeHeaderRows(sheet, columns) {
const top = columns.map((column) => column.group ?? column.header);
const bottom = columns.map((column) => (column.group ? (column.label ?? '') : ''));
/** @type {[number, number, number, number][]} */
const merges = [];
for (let index = 0; index < columns.length; ) {
const group = columns[index].group;
let end = index;
if (group) {
while (end + 1 < columns.length && columns[end + 1].group === group) {
end += 1;
top[end] = '';
}
if (end > index) {
merges.push([1, index + 1, 1, end + 1]);
}
} else {
merges.push([1, index + 1, 2, index + 1]);
}
index = end + 1;
}
const rows = [top, bottom].map((values) => {
const row = sheet.addRow(values);
row.font = { bold: true };
row.alignment = { vertical: 'middle', wrapText: true };
return row;
});
for (const [firstRow, firstColumn, lastRow, lastColumn] of merges) {
sheet.mergeCells(firstRow, firstColumn, lastRow, lastColumn);
}
for (const row of rows) {
row.commit();
}
}
/**
* @param {import('node:stream').Writable} stream
* @param {{
@ -339,27 +446,25 @@ export function questionRows(definition) {
* }} payload
*/
export async function writeWorkbook(stream, payload) {
const workbook = new ExcelJS.stream.xlsx.WorkbookWriter({ stream });
const responses = workbook.addWorksheet('Responses');
responses.columns = [...FIXED, ...payload.columns.map((column) => column.header)].map(
(header) => ({
header,
key: header,
width: 28,
}),
);
const workbook = new ExcelJS.stream.xlsx.WorkbookWriter({ stream, useStyles: true });
const responses = workbook.addWorksheet('Responses', {
views: [{ state: 'frozen', ySplit: 2 }],
});
const allColumns = [...FIXED, ...payload.columns];
responses.columns = allColumns.map((column) => ({ key: column.key, width: 28 }));
writeHeaderRows(responses, allColumns);
for (const row of payload.responses) {
/** @type {Record<string, string | number>} */
const values = {
'Application id': row.applicationId,
'Physician name': row.name,
'Registration no': row.registrationNo,
'Completion date': row.completedAt,
_applicationId: row.applicationId,
_name: row.name,
_registrationNo: row.registrationNo,
_completedAt: row.completedAt,
};
for (const column of payload.columns) {
values[column.header] = answerValue(row.definition, row.answers, column.key);
values[column.key] = answerValue(row.definition, row.answers, column.key);
}
responses.addRow(values).commit();
@ -374,6 +479,7 @@ export async function writeWorkbook(stream, payload) {
{ header: 'Type', key: 'type', width: 20 },
{ header: 'Options', key: 'options', width: 48 },
];
questions.getRow(1).font = { bold: true };
for (const row of questionRows(payload.definition)) {
questions.addRow(row).commit();
@ -387,6 +493,7 @@ export async function writeWorkbook(stream, payload) {
{ header: 'Field', key: 'field', width: 48 },
{ header: 'Value', key: 'value', width: 12 },
];
summary.getRow(1).font = { bold: true };
for (const row of summaryRows(payload.columns, payload.responses, payload.definition)) {
summary.addRow(row).commit();
@ -427,14 +534,17 @@ export async function loadExport(query) {
}
const records = await request;
const responses = records.map((row) => ({
applicationId: row.applicationId,
name: row.name ?? '',
registrationNo: row.registrationNo ?? '',
completedAt: row.completedAt ? String(row.completedAt) : '',
definition: parseJson(row.definition),
answers: parseJson(row.answers) ?? {},
}));
const responses = records.map((row) => {
const rowDefinition = parseJson(row.definition);
return {
applicationId: row.applicationId,
name: row.name ?? '',
registrationNo: row.registrationNo ?? '',
completedAt: row.completedAt ? String(row.completedAt) : '',
definition: rowDefinition,
answers: normalizeAnswers(rowDefinition, parseJson(row.answers) ?? {}),
};
});
const definition = active ? parseJson(active.definition) : { sections: [] };
const seen = new Set();
/** @type {{ key: string, header: string }[]} */

View File

@ -13,6 +13,53 @@ const packColumns = [
{ id: 'copd', label: 'COPD' },
];
const indianStatesAndUnionTerritories = [
'Andaman and Nicobar Islands',
'Andhra Pradesh',
'Arunachal Pradesh',
'Assam',
'Bihar',
'Chandigarh',
'Chhattisgarh',
'Dadra and Nagar Haveli and Daman and Diu',
'Delhi',
'Goa',
'Gujarat',
'Haryana',
'Himachal Pradesh',
'Jammu and Kashmir',
'Jharkhand',
'Karnataka',
'Kerala',
'Ladakh',
'Lakshadweep',
'Madhya Pradesh',
'Maharashtra',
'Manipur',
'Meghalaya',
'Mizoram',
'Nagaland',
'Odisha',
'Puducherry',
'Punjab',
'Rajasthan',
'Sikkim',
'Tamil Nadu',
'Telangana',
'Tripura',
'Uttar Pradesh',
'Uttarakhand',
'West Bengal',
];
const stateOptions = indianStatesAndUnionTerritories.map((label) => ({
id: label
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_|_$/g, ''),
label,
}));
const likertScale = [
{ id: 'strongly_disagree', label: 'Strongly disagree' },
{ id: 'disagree', label: 'Disagree' },
@ -62,7 +109,7 @@ export const surveyDefinition = {
},
{
id: 'practice_setting',
type: 'single_choice',
type: 'multi_choice',
label: 'Practice setting',
required: true,
options: [
@ -84,7 +131,14 @@ export const surveyDefinition = {
],
},
{ id: 'city', type: 'short_text', label: 'City', required: false },
{ id: 'state', type: 'short_text', label: 'State', required: false },
{
id: 'state',
type: 'single_choice',
display: 'dropdown',
label: 'State',
required: false,
options: stateOptions,
},
],
},
{

View File

@ -272,6 +272,56 @@ export function validateAnswers(definition, answers, options = {}) {
return errors;
}
/**
* Answers saved while a question was single-choice are stored as `{ choice }`;
* multi-choice questions expect `{ choices: [] }`. Answers saved while a question
* was free text are plain strings; choice questions expect an option id.
* @param {{ type: string, options?: { id: string, label: string }[] }} question
* @param {unknown} value
*/
function coerceAnswer(question, value) {
if (question.type === 'single_choice' && typeof value === 'string') {
const typed = value.trim().toLowerCase();
const match = question.options?.find(
(option) => option.label.toLowerCase() === typed || option.id === typed,
);
return match ? { choice: match.id, other: '' } : value;
}
if (
question.type === 'multi_choice' &&
value &&
typeof value === 'object' &&
!Array.isArray(/** @type {{ choices?: unknown }} */ (value).choices) &&
typeof (/** @type {{ choice?: unknown }} */ (value).choice) === 'string'
) {
const { choice, other } = /** @type {{ choice: string, other?: unknown }} */ (value);
return { choices: choice ? [choice] : [], other: other ?? '' };
}
return value;
}
/**
* Upgrade answers saved under an older shape of the same question.
* @param {{ sections: { questions: { id: string, type: string }[] }[] }} definition
* @param {Record<string, unknown>} answers
*/
export function normalizeAnswers(definition, answers) {
/** @type {Record<string, unknown>} */
const normalized = { ...answers };
for (const section of definition.sections) {
for (const question of section.questions) {
if (Object.prototype.hasOwnProperty.call(normalized, question.id)) {
normalized[question.id] = coerceAnswer(question, normalized[question.id]);
}
}
}
return normalized;
}
/**
* Keep only answers for real questions.
* @param {{ sections: { questions: { id: string, type: string }[] }[] }} definition
@ -288,7 +338,7 @@ export function knownAnswers(definition, answers) {
}
if (Object.prototype.hasOwnProperty.call(answers, question.id)) {
kept[question.id] = answers[question.id];
kept[question.id] = coerceAnswer(question, answers[question.id]);
}
}
}

View File

@ -0,0 +1,156 @@
import { randomUUID } from 'node:crypto';
import argon2 from 'argon2';
import ExcelJS from 'exceljs';
import { afterEach, describe, expect, it } from 'vitest';
import { CONSENT_QUESTIONS } from '../../frontend/src/consentQuestions.js';
import { createServer } from '../src/app.js';
import { db } from '../src/db/knex.js';
import { CONSENT_STATEMENTS } from '../src/schemas/consent.js';
import { encryptField } from '../src/services/fieldCrypto.js';
import { seedSurveyDefinition } from '../src/survey/seed.js';
import { truncateTables } from './truncate.js';
const email = 'applications-export-admin@example.com';
const password = 'correct-password';
/**
* @param {import('@hapi/hapi').ServerInjectResponse} response
*/
function sessionCookie(response) {
const raw = response.headers['set-cookie'];
const header = Array.isArray(raw) ? raw[0] : raw;
return header.split(';')[0];
}
describe('consent statements', () => {
it('match the statements shown on the consent page', () => {
expect(CONSENT_STATEMENTS).toEqual(
CONSENT_QUESTIONS.map(({ id, label }) => ({ id, label })),
);
});
});
describe('applications export', () => {
/** @type {import('@hapi/hapi').Server | undefined} */
let server;
afterEach(async () => {
await server?.stop();
await truncateTables();
});
it('writes application, consent and survey columns for the created-date range', async () => {
server = await createServer();
await db('admin_users').insert({
name: 'Administrator',
email,
password_hash: await argon2.hash(password, { type: argon2.argon2id }),
is_active: true,
});
const login = await server.inject({
method: 'POST',
url: '/api/admin/auth/login',
payload: { email, password },
});
const [userId] = await db('users').insert({ email: 'asha@example.com', name: 'Asha Rao' });
const [applicationId] = await db('applications').insert({
public_id: randomUUID(),
user_id: userId,
email: 'asha@example.com',
status: 'APPROVED',
full_name: 'Asha Rao',
registration_no: 'MMC-100',
pan: encryptField('ABCDE1234F'),
account_number: encryptField('001234567890'),
ifsc: 'HDFC0001234',
cancelled_cheque_key: 'cheques/1.png',
final_pdf_key: 'final/1.pdf',
version: 1,
created_at: '2026-10-10 09:00:00',
});
await db('applications').insert({
public_id: randomUUID(),
email: 'outside@example.com',
status: 'DRAFT',
version: 1,
created_at: '2026-09-01 09:00:00',
});
await db('signatures').insert({
application_id: applicationId,
version: 1,
type: 'TYPED',
typed_text: 'Asha Rao',
signed_at: new Date(),
});
await db('consents').insert({
user_id: userId,
application_id: applicationId,
participant_name: 'Asha Rao',
consent_date: '2026-10-10',
answers: JSON.stringify(
Object.fromEntries(CONSENT_STATEMENTS.map((statement) => [statement.id, true])),
),
submitted_at: '2026-10-10 09:05:00',
});
const definitionId = await seedSurveyDefinition();
await db('survey_responses').insert({
application_id: applicationId,
survey_definition_id: definitionId,
answers: JSON.stringify({}),
status: 'IN_PROGRESS',
started_at: new Date(),
});
const response = await server.inject({
method: 'GET',
url: '/api/admin/applications/export?from=2026-10-01&to=2026-10-31',
headers: { cookie: sessionCookie(login) },
});
expect(response.statusCode).toBe(200);
expect(response.headers['content-disposition']).toContain('applications-consent.xlsx');
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(response.rawPayload);
const sheet = workbook.getWorksheet('Applications');
const headers = sheet.getRow(1).values;
const cell = (/** @type {string} */ header) => sheet.getRow(2).getCell(headers.indexOf(header)).value;
expect(sheet.rowCount).toBe(2);
expect(sheet.getRow(1).getCell(1).font?.bold).toBe(true);
expect(cell('Status')).toBe('Approved');
expect(cell('PAN')).toBe('ABCDE1234F');
expect(cell('Account number')).toBe('001234567890');
expect(cell('Cheque uploaded')).toBe('Yes');
expect(cell('Signed')).toBe('Yes');
expect(cell('Final PDF available')).toBe('Yes');
expect(cell('Consent date')).toBe('2026-10-10');
expect(cell(CONSENT_STATEMENTS[0].label)).toBe('Yes');
expect(cell('I agree and consent to participate in the survey.')).toBe('Yes');
expect(cell('Survey status')).toBe('In progress');
expect(await db('audit_log').where({ action: 'APPLICATIONS_EXPORTED' }).first()).toBeTruthy();
});
it('rejects malformed dates', async () => {
server = await createServer();
await db('admin_users').insert({
name: 'Administrator',
email,
password_hash: await argon2.hash(password, { type: argon2.argon2id }),
is_active: true,
});
const login = await server.inject({
method: 'POST',
url: '/api/admin/auth/login',
payload: { email, password },
});
const response = await server.inject({
method: 'GET',
url: '/api/admin/applications/export?from=10-10-2026',
headers: { cookie: sessionCookie(login) },
});
expect(response.statusCode).toBe(400);
});
});

View File

@ -5,7 +5,7 @@ import { db } from '../src/db/knex.js';
import { generateToken, hashToken } from '../src/services/tokens.js';
import { surveyDefinition } from '../src/survey/definition.js';
import { seedSurveyDefinition } from '../src/survey/seed.js';
import { validateAnswers } from '../src/survey/validate.js';
import { knownAnswers, normalizeAnswers, validateAnswers } from '../src/survey/validate.js';
import { truncateTables } from './truncate.js';
/**
@ -216,6 +216,48 @@ describe('survey answers', () => {
).toEqual([]);
expect(validateAnswers(surveyDefinition, completeAnswers(surveyDefinition))).toEqual([]);
});
it('upgrades single-choice answers saved before a question became multi-choice', () => {
const setting = wrap({
id: 'practice_setting',
type: 'multi_choice',
label: 'Practice setting',
required: true,
options: [
{ id: 'private_clinic', label: 'Private Clinic' },
{ id: 'govt_hospital', label: 'Govt. Hospital' },
],
});
const legacy = { practice_setting: { choice: 'private_clinic', other: '' } };
expect(normalizeAnswers(setting, legacy)).toEqual({
practice_setting: { choices: ['private_clinic'], other: '' },
});
expect(knownAnswers(setting, legacy)).toEqual({
practice_setting: { choices: ['private_clinic'], other: '' },
});
expect(validateAnswers(setting, knownAnswers(setting, legacy))).toEqual([]);
});
it('upgrades a typed state to the matching dropdown option', () => {
const state = wrap({
id: 'state',
type: 'single_choice',
display: 'dropdown',
label: 'State',
required: false,
options: [
{ id: 'maharashtra', label: 'Maharashtra' },
{ id: 'tamil_nadu', label: 'Tamil Nadu' },
],
});
expect(knownAnswers(state, { state: ' tamil nadu ' })).toEqual({
state: { choice: 'tamil_nadu', other: '' },
});
expect(knownAnswers(state, { state: 'Bombay' })).toEqual({ state: 'Bombay' });
expect(validateAnswers(state, { state: 'Bombay' })).toEqual([]);
});
});
describe('survey submission', () => {

View File

@ -58,7 +58,10 @@ describe('survey export', () => {
application_id: applicationId,
survey_definition_id: definitionId,
answers: JSON.stringify({
name: 'Dr Asha Rao',
adherence_comparison: { choice: 'much_better' },
barriers: { choices: ['cost', 'other'], other: 'Lack of time' },
benefit: { choices: ['elderly'] },
pack_split: {
regular_pmdi: { asthma: 25, copd: 25 },
regular_rotacap: { asthma: 25, copd: 25 },
@ -82,13 +85,34 @@ describe('survey export', () => {
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(response.rawPayload);
const responses = workbook.getWorksheet('Responses');
const headers = responses.getRow(1).values.slice(1);
expect(headers).toContain(
const headings = responses.getRow(1).values.slice(1);
const optionLabels = responses.getRow(2).values;
const data = responses.getRow(3);
expect(headings).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);
headings.indexOf('Prescribing % - Extended-Dose Pack — Rotacap (pack of 60) - COPD') + 1;
expect(data.getCell(column).value).toBe(25);
expect(data.getCell(headings.indexOf('Physician name') + 1).value).toBe('Asha Rao');
const barriersHeading =
'Reasons for Non-adherence: In your opinion, what are the key barriers to adherence to inhaled maintenance therapy of formoterol/budesonide in your asthma and COPD patients?';
const barriersStart = headings.indexOf(barriersHeading) + 1;
expect(barriersStart).toBeGreaterThan(0);
expect(responses.getCell(1, barriersStart + 1).isMerged).toBe(true);
expect(optionLabels[barriersStart]).toBe('Side effects');
const barrierCell = (/** @type {string} */ label) =>
data.getCell(optionLabels.indexOf(label, barriersStart)).value;
expect(barrierCell('Cost')).toBe('Yes');
expect(barrierCell('Side effects')).toBeNull();
expect(barrierCell('Other (specify)')).toBe('Lack of time');
const elderly = optionLabels.indexOf('Elderly patients');
expect(responses.getCell(1, elderly).value).toBe(
'Patient Characteristics Associated with Adherence: Which patients, in your opinion, are most likely to benefit from an Extended-dose pack?',
);
expect(data.getCell(elderly).value).toBe('Yes');
const questions = workbook.getWorksheet('Questions');
const questionIds = questions.getColumn(1).values;
expect(questionIds).toContain('pack_split');

View File

@ -1,4 +1,4 @@
# Adherence Pack Study: Cursor Prompt Pack
# Extended-Dose Pack Study: Cursor Prompt Pack
## How to use this file
@ -18,7 +18,7 @@ Create `.cursor/rules/project.mdc` with this exact content:
```
---
description: Adherence Pack Study project context
description: Extended-Dose Pack Study project context
alwaysApply: true
---

View File

@ -9,7 +9,7 @@
href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<title>Adherence Pack Study</title>
<title>Extended-Dose Pack Study</title>
</head>
<body>
<div id="root"></div>

View File

@ -6,7 +6,7 @@ export default function SiteHeader() {
<header className="site-header">
<Link to="/" className="site-brand">
<img src={logo} alt="CREST" />
<span>Adherence Pack Study</span>
<span>Extended-Dose Pack Study</span>
</Link>
</header>
);

View File

@ -1,6 +1,16 @@
import { useState } from 'react';
export default function ReportsPage() {
/**
* @param {{
* id: string,
* title: string,
* description: string,
* endpoint: string,
* filename: string,
* baseParams?: Record<string, string>,
* }} props
*/
function ExportCard({ id, title, description, endpoint, filename, baseParams = {} }) {
const [from, setFrom] = useState('');
const [to, setTo] = useState('');
const [error, setError] = useState('');
@ -10,7 +20,7 @@ export default function ReportsPage() {
event.preventDefault();
setError('');
setBusy(true);
const params = new URLSearchParams({ status: 'COMPLETED' });
const params = new URLSearchParams(baseParams);
if (from) {
params.set('from', from);
@ -21,7 +31,7 @@ export default function ReportsPage() {
}
try {
const response = await fetch(`/api/admin/survey/export?${params}`, {
const response = await fetch(`${endpoint}?${params}`, {
credentials: 'include',
});
@ -39,7 +49,7 @@ export default function ReportsPage() {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'survey-responses.xlsx';
link.download = filename;
link.click();
URL.revokeObjectURL(url);
} catch (downloadError) {
@ -49,38 +59,74 @@ export default function ReportsPage() {
}
}
return (
<form
className="admin-card grid gap-3"
onSubmit={download}
aria-labelledby={`${id}-heading`}
>
<div>
<h2 id={`${id}-heading`} className="font-semibold text-[#134e4a]">
{title}
</h2>
<p className="mt-1 text-sm text-stone-600">{description}</p>
</div>
<label className="grid gap-1 text-sm font-medium text-stone-600" htmlFor={`${id}-from`}>
From
<input
id={`${id}-from`}
type="date"
className="admin-input"
value={from}
onChange={(event) => setFrom(event.target.value)}
/>
</label>
<label className="grid gap-1 text-sm font-medium text-stone-600" htmlFor={`${id}-to`}>
To
<input
id={`${id}-to`}
type="date"
className="admin-input"
value={to}
onChange={(event) => setTo(event.target.value)}
/>
</label>
<button type="submit" className="admin-btn admin-btn-primary" disabled={busy}>
{busy ? 'Exporting…' : 'Export to Excel'}
</button>
{error ? (
<p className="admin-card-status admin-card-status-error" role="alert">
{error}
</p>
) : null}
</form>
);
}
export default function ReportsPage() {
return (
<div className="max-w-xl">
<h1 className="admin-title">Reports</h1>
<p className="mb-4 rounded-lg bg-amber-50 px-3 py-2 text-sm text-amber-900" role="status">
This file contains personal data.
These files contain personal data.
</p>
<form className="admin-card grid gap-3" onSubmit={download}>
<label className="grid gap-1 text-sm font-medium text-stone-600" htmlFor="export-from">
From
<input
id="export-from"
type="date"
className="admin-input"
value={from}
onChange={(event) => setFrom(event.target.value)}
/>
</label>
<label className="grid gap-1 text-sm font-medium text-stone-600" htmlFor="export-to">
To
<input
id="export-to"
type="date"
className="admin-input"
value={to}
onChange={(event) => setTo(event.target.value)}
/>
</label>
<button type="submit" className="admin-btn admin-btn-primary" disabled={busy}>
{busy ? 'Exporting…' : 'Export to Excel'}
</button>
</form>
{error ? <p className="mt-3 text-red-700">{error}</p> : null}
<div className="grid gap-4">
<ExportCard
id="survey-export"
title="Export Survey Data"
description="Completed survey responses, filtered by completion date."
endpoint="/api/admin/survey/export"
filename="survey-responses.xlsx"
baseParams={{ status: 'COMPLETED' }}
/>
<ExportCard
id="applications-export"
title="Export Applications with consent"
description="Applications with consent and survey status, filtered by application created date. Includes full PAN and bank account numbers."
endpoint="/api/admin/applications/export"
filename="applications-consent.xlsx"
/>
</div>
</div>
);
}

View File

@ -0,0 +1,61 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import ReportsPage from './ReportsPage.jsx';
describe('reports page', () => {
const fetchMock = vi.fn();
beforeEach(() => {
fetchMock.mockReset();
fetchMock.mockResolvedValue({ ok: true, status: 200, blob: async () => new Blob(['x']) });
vi.stubGlobal('fetch', fetchMock);
URL.createObjectURL = vi.fn(() => 'blob:export');
URL.revokeObjectURL = vi.fn();
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
it('shows a survey card and an applications card', () => {
render(<ReportsPage />);
expect(screen.getByRole('heading', { name: 'Export Survey Data' })).toBeInTheDocument();
expect(
screen.getByRole('heading', { name: 'Export Applications with consent' }),
).toBeInTheDocument();
expect(screen.getAllByRole('button', { name: 'Export to Excel' })).toHaveLength(2);
});
it('exports applications with the dates chosen in that card only', async () => {
render(<ReportsPage />);
const card = screen.getByRole('form', { name: 'Export Applications with consent' });
const [from, to] = card.querySelectorAll('input[type="date"]');
fireEvent.change(from, { target: { value: '2026-10-01' } });
fireEvent.change(to, { target: { value: '2026-10-23' } });
fireEvent.click(card.querySelector('button'));
await waitFor(() =>
expect(fetchMock).toHaveBeenCalledWith(
'/api/admin/applications/export?from=2026-10-01&to=2026-10-23',
expect.objectContaining({ credentials: 'include' }),
),
);
});
it('keeps the survey export on completed responses', async () => {
render(<ReportsPage />);
const card = screen.getByRole('form', { name: 'Export Survey Data' });
fireEvent.click(card.querySelector('button'));
await waitFor(() =>
expect(fetchMock).toHaveBeenCalledWith(
'/api/admin/survey/export?status=COMPLETED',
expect.objectContaining({ credentials: 'include' }),
),
);
});
});

View File

@ -735,6 +735,8 @@ a:focus-visible {
.survey-total.warn {
color: #b45309;
font-size: 0.78rem;
line-height: 1.35;
}
.cell-label,

View File

@ -8,8 +8,8 @@ export default function PrivacyPage() {
How Chest Research and Training Pvt. Ltd. uses information for this study.
</p>
<p>
Chest Research and Training Pvt. Ltd. uses the personal information you provide for the Adherence Pack
Study, for payments, for statutory compliance, and for audit.
Chest Research and Training Pvt. Ltd. uses the personal information you provide for the
Extended-Dose Pack Study, for payments, for statutory compliance, and for audit.
</p>
<p>
That includes your name, contact and registration details, PAN, bank account, signature,

View File

@ -70,6 +70,62 @@ function formatAnswer(question, value) {
return '—';
}
/**
* Number inputs accept "e", "+" and "-" even when min/max forbid them.
* @param {{ min?: number, integer?: boolean }} question
*/
function rangeKeyDown(question) {
return (/** @type {import('react').KeyboardEvent<HTMLInputElement>} */ event) => {
const blocked = ['e', 'E', '+'];
if (typeof question.min === 'number' && question.min >= 0) {
blocked.push('-');
}
if (question.integer) {
blocked.push('.', ',');
}
if (blocked.includes(event.key)) {
event.preventDefault();
}
};
}
/**
* @param {{ min?: number, max?: number }} question
* @param {string} raw
*/
function inRange(question, raw) {
if (raw === '') {
return true;
}
const number = Number(raw);
if (Number.isNaN(number)) {
return false;
}
if (typeof question.min === 'number' && number < question.min) {
return false;
}
if (typeof question.max === 'number' && number > question.max) {
return false;
}
return true;
}
/**
* A focused number input changes value on mouse-wheel scroll.
* @param {import('react').WheelEvent<HTMLInputElement>} event
*/
function blurOnWheel(event) {
event.currentTarget.blur();
}
/**
* @param {{ messages: string[] }} props
*/
@ -147,8 +203,13 @@ function QuestionField({
max={question.max}
aria-invalid={invalid || undefined}
value={value === undefined || value === null ? '' : value}
onKeyDown={rangeKeyDown(question)}
onWheel={blurOnWheel}
onChange={(event) => {
const raw = event.target.value;
if (!inRange(question, raw)) {
return;
}
onChange(raw === '' ? '' : Number(raw));
}}
/>
@ -158,6 +219,32 @@ function QuestionField({
);
}
if (question.type === 'single_choice' && question.display === 'dropdown') {
const current = value && typeof value === 'object' ? value : {};
return (
<div className="survey-field">
<label>
{question.label}
<select
aria-invalid={invalid || undefined}
value={current.choice ?? ''}
onChange={(event) =>
onChange(event.target.value ? { choice: event.target.value, other: '' } : '')
}
>
<option value="">Select {question.label.toLowerCase()}</option>
{question.options.map((option) => (
<option key={option.id} value={option.id}>
{option.label}
</option>
))}
</select>
</label>
{errors}
</div>
);
}
if (question.type === 'single_choice' || question.type === 'multi_choice') {
const current = value && typeof value === 'object' ? value : {};
return (
@ -241,8 +328,13 @@ function QuestionField({
aria-invalid={invalid || undefined}
aria-label={`${row.label}, ${column.label}`}
value={grid[row.id]?.[column.id] ?? ''}
onKeyDown={rangeKeyDown(question)}
onWheel={blurOnWheel}
onChange={(event) => {
const raw = event.target.value;
if (!inRange(question, raw)) {
return;
}
onChange({
...grid,
[row.id]: {

View File

@ -61,18 +61,70 @@ describe('survey steps', () => {
);
});
it('keeps an out-of-range answer on the current step', async () => {
it('ignores percentage input outside 0 to 100', async () => {
renderSurvey();
const field = await screen.findByLabelText(PROPORTION);
fireEvent.change(field, { target: { value: '150' } });
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
expect(
await screen.findByText((text) => text.includes(`${PROPORTION} must be at most 100`)),
).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Foracort prescribing pattern' })).toBeInTheDocument();
expect(screen.queryByRole('heading', { name: 'Notes' })).not.toBeInTheDocument();
expect(screen.queryByRole('heading', { name: 'Review' })).not.toBeInTheDocument();
fireEvent.change(field, { target: { value: '40' } });
expect(field).toHaveValue(40);
fireEvent.change(field, { target: { value: '150' } });
expect(field).toHaveValue(40);
fireEvent.change(field, { target: { value: '-5' } });
expect(field).toHaveValue(40);
fireEvent.change(field, { target: { value: '100' } });
expect(field).toHaveValue(100);
});
it('blocks the minus and exponent keys when the minimum is zero', async () => {
renderSurvey();
const field = await screen.findByLabelText(PROPORTION);
expect(fireEvent.keyDown(field, { key: '-' })).toBe(false);
expect(fireEvent.keyDown(field, { key: 'e' })).toBe(false);
expect(fireEvent.keyDown(field, { key: '5' })).toBe(true);
});
it('ignores grid cell input outside 0 to 100', async () => {
const withGrid = {
...survey,
definition: {
sections: [
{
id: 'pack',
title: 'Pack split',
questions: [
{
id: 'pack_split',
type: 'grid_percentage',
label: 'Pack type',
required: true,
min: 0,
max: 100,
sumTo: 100,
rows: [{ id: 'regular_pmdi', label: 'Regular pMDI' }],
columns: [{ id: 'asthma', label: 'Asthma' }],
},
],
},
],
},
};
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => withGrid }));
renderSurvey();
const cell = await screen.findByLabelText('Regular pMDI, Asthma');
fireEvent.change(cell, { target: { value: '60' } });
expect(cell).toHaveValue(60);
fireEvent.change(cell, { target: { value: '101' } });
expect(cell).toHaveValue(60);
fireEvent.change(cell, { target: { value: '-1' } });
expect(cell).toHaveValue(60);
});
it('keeps a missing required answer on the current step', async () => {
@ -119,4 +171,43 @@ describe('survey steps', () => {
expect.objectContaining({ method: 'PUT' }),
);
});
it('renders a dropdown question as a select', async () => {
const withState = {
...survey,
definition: {
sections: [
{
id: 'demographics',
title: 'Doctor details',
questions: [
{
id: 'state',
type: 'single_choice',
display: 'dropdown',
label: 'State',
required: false,
options: [
{ id: 'goa', label: 'Goa' },
{ id: 'kerala', label: 'Kerala' },
],
},
],
},
],
},
};
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ok: true, json: async () => withState }),
);
renderSurvey();
const select = await screen.findByRole('combobox', { name: 'State' });
expect(select).toHaveValue('');
expect(screen.getByRole('option', { name: 'Select state' })).toBeInTheDocument();
fireEvent.change(select, { target: { value: 'kerala' } });
expect(select).toHaveValue('kerala');
});
});

View File

@ -64,7 +64,7 @@ export default function UserLayout() {
<div className="user-topbar-inner">
<Link to="/dashboard" className="user-brand">
<img src={logo} alt="CREST" />
<span>Adherence Pack Study</span>
<span>Extended-Dose Pack Study</span>
</Link>
<div className="user-menu" ref={menuRef}>
<button

View File

@ -11,10 +11,10 @@
- **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
- **Practice setting** (multi-select): Private Clinic / Private Hospital / Govt. Hospital
- **Specialization** (single choice): Chest Physician / Consulting Physician / Other (specify): ______
- **City:** ______
- **State:** ______
- **State** (dropdown): all Indian states and union territories
## Section 2: Physician Practice Profile