diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index 3f80fd5..e5b00d5 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -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', diff --git a/backend/src/schemas/consent.js b/backend/src/schemas/consent.js index 8fc41a8..544a884 100644 --- a/backend/src/schemas/consent.js +++ b/backend/src/schemas/consent.js @@ -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' }), }); diff --git a/backend/src/services/applicationsExport.js b/backend/src/services/applicationsExport.js new file mode 100644 index 0000000..a1ed659 --- /dev/null +++ b/backend/src/services/applicationsExport.js @@ -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} record + * @param {Set} signedIds + */ +export function applicationExportRow(record, signedIds) { + const consentAnswers = record.consentId ? (parseJson(record.consentAnswers) ?? {}) : null; + /** @type {Record} */ + 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[]} 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; +} diff --git a/backend/src/services/emailTemplates.js b/backend/src/services/emailTemplates.js index 7a49c1b..f2ed624 100644 --- a/backend/src/services/emailTemplates.js +++ b/backend/src/services/emailTemplates.js @@ -32,7 +32,7 @@ function layout(content) {

${escapeHtml(content.heading)}

${paragraphs} ${action} -

Adherence Pack Study

+

Extended-Dose Pack Study

`; @@ -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' }, }); diff --git a/backend/src/services/survey.js b/backend/src/services/survey.js index 970bedc..e52e72b 100644 --- a/backend/src/services/survey.js +++ b/backend/src/services/survey.js @@ -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) { diff --git a/backend/src/services/surveyExport.js b/backend/src/services/surveyExport.js index 906fb07..1dcdee0 100644 --- a/backend/src/services/surveyExport.js +++ b/backend/src/services/surveyExport.js @@ -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} 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[] }[] }} 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} */ 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} */ 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 }[]} */ diff --git a/backend/src/survey/definition.js b/backend/src/survey/definition.js index 6ec7137..307c4ed 100644 --- a/backend/src/survey/definition.js +++ b/backend/src/survey/definition.js @@ -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, + }, ], }, { diff --git a/backend/src/survey/validate.js b/backend/src/survey/validate.js index bfa75e3..837dd01 100644 --- a/backend/src/survey/validate.js +++ b/backend/src/survey/validate.js @@ -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} answers + */ +export function normalizeAnswers(definition, answers) { + /** @type {Record} */ + 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]); } } } diff --git a/backend/test/applicationsExport.test.js b/backend/test/applicationsExport.test.js new file mode 100644 index 0000000..353c526 --- /dev/null +++ b/backend/test/applicationsExport.test.js @@ -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); + }); +}); diff --git a/backend/test/survey.test.js b/backend/test/survey.test.js index 1542346..bda8741 100644 --- a/backend/test/survey.test.js +++ b/backend/test/survey.test.js @@ -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', () => { diff --git a/backend/test/surveyExport.test.js b/backend/test/surveyExport.test.js index 0563ff0..bd2728f 100644 --- a/backend/test/surveyExport.test.js +++ b/backend/test/surveyExport.test.js @@ -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'); diff --git a/files/cursor_prompts.md b/files/cursor_prompts.md index 8014726..375f700 100644 --- a/files/cursor_prompts.md +++ b/files/cursor_prompts.md @@ -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 --- diff --git a/frontend/index.html b/frontend/index.html index d639983..e683cf3 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -9,7 +9,7 @@ href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap" rel="stylesheet" /> - Adherence Pack Study + Extended-Dose Pack Study
diff --git a/frontend/src/SiteHeader.jsx b/frontend/src/SiteHeader.jsx index 2f1e941..4de04b5 100644 --- a/frontend/src/SiteHeader.jsx +++ b/frontend/src/SiteHeader.jsx @@ -6,7 +6,7 @@ export default function SiteHeader() {
CREST - Adherence Pack Study + Extended-Dose Pack Study
); diff --git a/frontend/src/admin/ReportsPage.jsx b/frontend/src/admin/ReportsPage.jsx index 26d2e0b..ec378cc 100644 --- a/frontend/src/admin/ReportsPage.jsx +++ b/frontend/src/admin/ReportsPage.jsx @@ -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, + * }} 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 ( +
+
+

+ {title} +

+

{description}

+
+ + + + {error ? ( +

+ {error} +

+ ) : null} +
+ ); +} + +export default function ReportsPage() { return (

Reports

- This file contains personal data. + These files contain personal data.

-
- - - -
- {error ?

{error}

: null} +
+ + +
); } diff --git a/frontend/src/admin/ReportsPage.test.jsx b/frontend/src/admin/ReportsPage.test.jsx new file mode 100644 index 0000000..bea2668 --- /dev/null +++ b/frontend/src/admin/ReportsPage.test.jsx @@ -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(); + + 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(); + 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(); + 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' }), + ), + ); + }); +}); diff --git a/frontend/src/index.css b/frontend/src/index.css index 78b1d1c..89cbef4 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -735,6 +735,8 @@ a:focus-visible { .survey-total.warn { color: #b45309; + font-size: 0.78rem; + line-height: 1.35; } .cell-label, diff --git a/frontend/src/pages/PrivacyPage.jsx b/frontend/src/pages/PrivacyPage.jsx index ca63d77..9f6d731 100644 --- a/frontend/src/pages/PrivacyPage.jsx +++ b/frontend/src/pages/PrivacyPage.jsx @@ -8,8 +8,8 @@ export default function PrivacyPage() { How Chest Research and Training Pvt. Ltd. uses information for this study.

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

That includes your name, contact and registration details, PAN, bank account, signature, diff --git a/frontend/src/pages/SurveyPage.jsx b/frontend/src/pages/SurveyPage.jsx index 56e6533..29a9f4a 100644 --- a/frontend/src/pages/SurveyPage.jsx +++ b/frontend/src/pages/SurveyPage.jsx @@ -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} */ 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} 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 ( +

+ + {errors} +
+ ); + } + 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]: { diff --git a/frontend/src/pages/SurveyPage.test.jsx b/frontend/src/pages/SurveyPage.test.jsx index fb948d1..3c9658c 100644 --- a/frontend/src/pages/SurveyPage.test.jsx +++ b/frontend/src/pages/SurveyPage.test.jsx @@ -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'); + }); }); diff --git a/frontend/src/user/UserLayout.jsx b/frontend/src/user/UserLayout.jsx index 9e729dd..a12b9fb 100644 --- a/frontend/src/user/UserLayout.jsx +++ b/frontend/src/user/UserLayout.jsx @@ -64,7 +64,7 @@ export default function UserLayout() {
CREST - Adherence Pack Study + Extended-Dose Pack Study