###---- 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:
parent
29f879c5d1
commit
125e679019
|
|
@ -2,6 +2,7 @@ import Boom from '@hapi/boom';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { auditFromRequest } from '../middleware/audit.js';
|
import { auditFromRequest } from '../middleware/audit.js';
|
||||||
import { requireAdmin } from '../middleware/requireAdmin.js';
|
import { requireAdmin } from '../middleware/requireAdmin.js';
|
||||||
|
import { exportApplicationsWorkbook } from '../services/applicationsExport.js';
|
||||||
import { exportWorkbook } from '../services/surveyExport.js';
|
import { exportWorkbook } from '../services/surveyExport.js';
|
||||||
import { getReminderSettings, saveReminderSettings } from '../services/settings.js';
|
import { getReminderSettings, saveReminderSettings } from '../services/settings.js';
|
||||||
import {
|
import {
|
||||||
|
|
@ -25,22 +26,23 @@ const pre = [requireAdmin];
|
||||||
|
|
||||||
const blankToUndefined = (value) => (value === '' || value === undefined ? undefined : value);
|
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({
|
const exportQuerySchema = z.object({
|
||||||
status: z.preprocess(blankToUndefined, z.enum(['COMPLETED']).default('COMPLETED')),
|
status: z.preprocess(blankToUndefined, z.enum(['COMPLETED']).default('COMPLETED')),
|
||||||
from: z.preprocess(
|
from: optionalDate,
|
||||||
blankToUndefined,
|
to: optionalDate,
|
||||||
z
|
});
|
||||||
.string()
|
|
||||||
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
const applicationsExportQuerySchema = z.object({
|
||||||
.optional(),
|
from: optionalDate,
|
||||||
),
|
to: optionalDate,
|
||||||
to: z.preprocess(
|
|
||||||
blankToUndefined,
|
|
||||||
z
|
|
||||||
.string()
|
|
||||||
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
|
||||||
.optional(),
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const reminderSchema = z.object({
|
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({
|
server.route({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: '/api/admin/audit-log',
|
path: '/api/admin/audit-log',
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,55 @@
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export const CONSENT_ANSWER_KEYS = [
|
/** Must match `frontend/src/consentQuestions.js`; a test checks both lists. */
|
||||||
'voluntary_withdraw',
|
export const CONSENT_STATEMENTS = [
|
||||||
'mou_required',
|
{
|
||||||
'eligibility_six_months',
|
id: 'voluntary_withdraw',
|
||||||
'confidential_research',
|
label:
|
||||||
'web_questionnaire',
|
'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.',
|
||||||
'no_intervention',
|
},
|
||||||
'aggregated_reporting',
|
{
|
||||||
'honorarium',
|
id: 'mou_required',
|
||||||
'agree_participate',
|
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, {
|
const requiredTrue = z.literal(true, {
|
||||||
errorMap: () => ({ message: 'This confirmation is required' }),
|
errorMap: () => ({ message: 'This confirmation is required' }),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -32,7 +32,7 @@ function layout(content) {
|
||||||
<h1 style="font-size:24px;font-weight:600;margin:0 0 16px;">${escapeHtml(content.heading)}</h1>
|
<h1 style="font-size:24px;font-weight:600;margin:0 0 16px;">${escapeHtml(content.heading)}</h1>
|
||||||
${paragraphs}
|
${paragraphs}
|
||||||
${action}
|
${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>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
|
|
@ -63,7 +63,7 @@ export function renderEmail(name, data) {
|
||||||
subject: 'Your sign-in code',
|
subject: 'Your sign-in code',
|
||||||
heading: 'Your sign-in code',
|
heading: 'Your sign-in code',
|
||||||
paragraphs: [
|
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 || '',
|
data.otp || '',
|
||||||
'If you did not request this code, you can ignore this email.',
|
'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',
|
heading: 'Continue your agreement',
|
||||||
paragraphs: [
|
paragraphs: [
|
||||||
`${physician}, your consultation agreement is still a draft.`,
|
`${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' },
|
action: { href: data.url, label: 'Continue your agreement' },
|
||||||
});
|
});
|
||||||
|
|
@ -131,7 +131,7 @@ export function renderEmail(name, data) {
|
||||||
heading: 'Your survey is waiting',
|
heading: 'Your survey is waiting',
|
||||||
paragraphs: [
|
paragraphs: [
|
||||||
`${physician}, your consultation agreement is approved and the survey is not yet submitted.`,
|
`${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' },
|
action: { href: data.url, label: 'Open the survey' },
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import Boom from '@hapi/boom';
|
||||||
import { db } from '../db/knex.js';
|
import { db } from '../db/knex.js';
|
||||||
import { recordAudit } from './audit.js';
|
import { recordAudit } from './audit.js';
|
||||||
import { generateToken, hashToken } from './tokens.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';
|
import { stripHtmlDeep } from './sanitize.js';
|
||||||
|
|
||||||
const INVALID = 'This survey link is invalid or has already been used';
|
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) {
|
function present(definitionRow, response, application) {
|
||||||
const definition = parseJson(definitionRow.definition);
|
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 (typeof answers.name !== 'string' || !answers.name.trim()) {
|
||||||
if (application.full_name) {
|
if (application.full_name) {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,26 @@
|
||||||
import { PassThrough } from 'node:stream';
|
import { PassThrough } from 'node:stream';
|
||||||
import ExcelJS from 'exceljs';
|
import ExcelJS from 'exceljs';
|
||||||
import { db } from '../db/knex.js';
|
import { db } from '../db/knex.js';
|
||||||
|
import { normalizeAnswers } from '../survey/validate.js';
|
||||||
|
|
||||||
const SKIPPED = new Set(['help', 'section_header']);
|
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
|
* @param {unknown} value
|
||||||
|
|
@ -35,7 +52,7 @@ function gridPrefix(questionId) {
|
||||||
* @param {{ sections: { title: string, questions: Record<string, any>[] }[] }} definition
|
* @param {{ sections: { title: string, questions: Record<string, any>[] }[] }} definition
|
||||||
*/
|
*/
|
||||||
export function answerColumns(definition) {
|
export function answerColumns(definition) {
|
||||||
/** @type {{ key: string, header: string }[]} */
|
/** @type {{ key: string, header: string, group?: string, label?: string, context?: string }[]} */
|
||||||
const columns = [];
|
const columns = [];
|
||||||
/** @type {Map<string, number>} */
|
/** @type {Map<string, number>} */
|
||||||
const labelCounts = new Map();
|
const labelCounts = new Map();
|
||||||
|
|
@ -76,6 +93,21 @@ export function answerColumns(definition) {
|
||||||
continue;
|
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);
|
labelCounts.set(question.label, (labelCounts.get(question.label) ?? 0) + 1);
|
||||||
columns.push({
|
columns.push({
|
||||||
key: question.id,
|
key: question.id,
|
||||||
|
|
@ -87,6 +119,10 @@ export function answerColumns(definition) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return columns.map((column) => {
|
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) {
|
if (!column.label || labelCounts.get(column.label) === 1) {
|
||||||
return { key: column.key, header: column.header };
|
return { key: column.key, header: column.header };
|
||||||
}
|
}
|
||||||
|
|
@ -180,6 +216,23 @@ export function answerValue(definition, answers, key) {
|
||||||
return typeof value === 'string' ? value : '';
|
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') {
|
if (question.type === 'grid_percentage' || question.type === 'grid_number') {
|
||||||
for (const row of question.rows) {
|
for (const row of question.rows) {
|
||||||
for (const column of question.columns) {
|
for (const column of question.columns) {
|
||||||
|
|
@ -216,6 +269,7 @@ export function answerValue(definition, answers, key) {
|
||||||
export function summaryRows(columns, rows, definition) {
|
export function summaryRows(columns, rows, definition) {
|
||||||
/** @type {{ kind: string, field: string, value: number }[]} */
|
/** @type {{ kind: string, field: string, value: number }[]} */
|
||||||
const summary = [];
|
const summary = [];
|
||||||
|
const counted = new Set();
|
||||||
|
|
||||||
for (const column of columns) {
|
for (const column of columns) {
|
||||||
const question = questionFor(definition, column.key) ?? findQuestion(rows, column.key);
|
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 (question.type === 'single_choice' || question.type === 'multi_choice') {
|
||||||
|
if (counted.has(question.id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
counted.add(question.id);
|
||||||
for (const option of question.options ?? []) {
|
for (const option of question.options ?? []) {
|
||||||
const count = rows.filter((row) => selected(question, row.answers, option.id)).length;
|
const count = rows.filter((row) => selected(question, row.answers, option.id)).length;
|
||||||
summary.push({ kind: 'Count', field: `${question.label}: ${option.label}`, value: count });
|
summary.push({ kind: 'Count', field: `${question.label}: ${option.label}`, value: count });
|
||||||
|
|
@ -330,6 +389,54 @@ export function questionRows(definition) {
|
||||||
return rows;
|
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 {import('node:stream').Writable} stream
|
||||||
* @param {{
|
* @param {{
|
||||||
|
|
@ -339,27 +446,25 @@ export function questionRows(definition) {
|
||||||
* }} payload
|
* }} payload
|
||||||
*/
|
*/
|
||||||
export async function writeWorkbook(stream, payload) {
|
export async function writeWorkbook(stream, payload) {
|
||||||
const workbook = new ExcelJS.stream.xlsx.WorkbookWriter({ stream });
|
const workbook = new ExcelJS.stream.xlsx.WorkbookWriter({ stream, useStyles: true });
|
||||||
const responses = workbook.addWorksheet('Responses');
|
const responses = workbook.addWorksheet('Responses', {
|
||||||
responses.columns = [...FIXED, ...payload.columns.map((column) => column.header)].map(
|
views: [{ state: 'frozen', ySplit: 2 }],
|
||||||
(header) => ({
|
});
|
||||||
header,
|
const allColumns = [...FIXED, ...payload.columns];
|
||||||
key: header,
|
responses.columns = allColumns.map((column) => ({ key: column.key, width: 28 }));
|
||||||
width: 28,
|
writeHeaderRows(responses, allColumns);
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const row of payload.responses) {
|
for (const row of payload.responses) {
|
||||||
/** @type {Record<string, string | number>} */
|
/** @type {Record<string, string | number>} */
|
||||||
const values = {
|
const values = {
|
||||||
'Application id': row.applicationId,
|
_applicationId: row.applicationId,
|
||||||
'Physician name': row.name,
|
_name: row.name,
|
||||||
'Registration no': row.registrationNo,
|
_registrationNo: row.registrationNo,
|
||||||
'Completion date': row.completedAt,
|
_completedAt: row.completedAt,
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const column of payload.columns) {
|
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();
|
responses.addRow(values).commit();
|
||||||
|
|
@ -374,6 +479,7 @@ export async function writeWorkbook(stream, payload) {
|
||||||
{ header: 'Type', key: 'type', width: 20 },
|
{ header: 'Type', key: 'type', width: 20 },
|
||||||
{ header: 'Options', key: 'options', width: 48 },
|
{ header: 'Options', key: 'options', width: 48 },
|
||||||
];
|
];
|
||||||
|
questions.getRow(1).font = { bold: true };
|
||||||
|
|
||||||
for (const row of questionRows(payload.definition)) {
|
for (const row of questionRows(payload.definition)) {
|
||||||
questions.addRow(row).commit();
|
questions.addRow(row).commit();
|
||||||
|
|
@ -387,6 +493,7 @@ export async function writeWorkbook(stream, payload) {
|
||||||
{ header: 'Field', key: 'field', width: 48 },
|
{ header: 'Field', key: 'field', width: 48 },
|
||||||
{ header: 'Value', key: 'value', width: 12 },
|
{ header: 'Value', key: 'value', width: 12 },
|
||||||
];
|
];
|
||||||
|
summary.getRow(1).font = { bold: true };
|
||||||
|
|
||||||
for (const row of summaryRows(payload.columns, payload.responses, payload.definition)) {
|
for (const row of summaryRows(payload.columns, payload.responses, payload.definition)) {
|
||||||
summary.addRow(row).commit();
|
summary.addRow(row).commit();
|
||||||
|
|
@ -427,14 +534,17 @@ export async function loadExport(query) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const records = await request;
|
const records = await request;
|
||||||
const responses = records.map((row) => ({
|
const responses = records.map((row) => {
|
||||||
applicationId: row.applicationId,
|
const rowDefinition = parseJson(row.definition);
|
||||||
name: row.name ?? '',
|
return {
|
||||||
registrationNo: row.registrationNo ?? '',
|
applicationId: row.applicationId,
|
||||||
completedAt: row.completedAt ? String(row.completedAt) : '',
|
name: row.name ?? '',
|
||||||
definition: parseJson(row.definition),
|
registrationNo: row.registrationNo ?? '',
|
||||||
answers: parseJson(row.answers) ?? {},
|
completedAt: row.completedAt ? String(row.completedAt) : '',
|
||||||
}));
|
definition: rowDefinition,
|
||||||
|
answers: normalizeAnswers(rowDefinition, parseJson(row.answers) ?? {}),
|
||||||
|
};
|
||||||
|
});
|
||||||
const definition = active ? parseJson(active.definition) : { sections: [] };
|
const definition = active ? parseJson(active.definition) : { sections: [] };
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
/** @type {{ key: string, header: string }[]} */
|
/** @type {{ key: string, header: string }[]} */
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,53 @@ const packColumns = [
|
||||||
{ id: 'copd', label: 'COPD' },
|
{ 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 = [
|
const likertScale = [
|
||||||
{ id: 'strongly_disagree', label: 'Strongly disagree' },
|
{ id: 'strongly_disagree', label: 'Strongly disagree' },
|
||||||
{ id: 'disagree', label: 'Disagree' },
|
{ id: 'disagree', label: 'Disagree' },
|
||||||
|
|
@ -62,7 +109,7 @@ export const surveyDefinition = {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'practice_setting',
|
id: 'practice_setting',
|
||||||
type: 'single_choice',
|
type: 'multi_choice',
|
||||||
label: 'Practice setting',
|
label: 'Practice setting',
|
||||||
required: true,
|
required: true,
|
||||||
options: [
|
options: [
|
||||||
|
|
@ -84,7 +131,14 @@ export const surveyDefinition = {
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ id: 'city', type: 'short_text', label: 'City', required: false },
|
{ 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,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -272,6 +272,56 @@ export function validateAnswers(definition, answers, options = {}) {
|
||||||
return errors;
|
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.
|
* Keep only answers for real questions.
|
||||||
* @param {{ sections: { questions: { id: string, type: string }[] }[] }} definition
|
* @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)) {
|
if (Object.prototype.hasOwnProperty.call(answers, question.id)) {
|
||||||
kept[question.id] = answers[question.id];
|
kept[question.id] = coerceAnswer(question, answers[question.id]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -5,7 +5,7 @@ import { db } from '../src/db/knex.js';
|
||||||
import { generateToken, hashToken } from '../src/services/tokens.js';
|
import { generateToken, hashToken } from '../src/services/tokens.js';
|
||||||
import { surveyDefinition } from '../src/survey/definition.js';
|
import { surveyDefinition } from '../src/survey/definition.js';
|
||||||
import { seedSurveyDefinition } from '../src/survey/seed.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';
|
import { truncateTables } from './truncate.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -216,6 +216,48 @@ describe('survey answers', () => {
|
||||||
).toEqual([]);
|
).toEqual([]);
|
||||||
expect(validateAnswers(surveyDefinition, completeAnswers(surveyDefinition))).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', () => {
|
describe('survey submission', () => {
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,10 @@ describe('survey export', () => {
|
||||||
application_id: applicationId,
|
application_id: applicationId,
|
||||||
survey_definition_id: definitionId,
|
survey_definition_id: definitionId,
|
||||||
answers: JSON.stringify({
|
answers: JSON.stringify({
|
||||||
|
name: 'Dr Asha Rao',
|
||||||
adherence_comparison: { choice: 'much_better' },
|
adherence_comparison: { choice: 'much_better' },
|
||||||
|
barriers: { choices: ['cost', 'other'], other: 'Lack of time' },
|
||||||
|
benefit: { choices: ['elderly'] },
|
||||||
pack_split: {
|
pack_split: {
|
||||||
regular_pmdi: { asthma: 25, copd: 25 },
|
regular_pmdi: { asthma: 25, copd: 25 },
|
||||||
regular_rotacap: { asthma: 25, copd: 25 },
|
regular_rotacap: { asthma: 25, copd: 25 },
|
||||||
|
|
@ -82,13 +85,34 @@ describe('survey export', () => {
|
||||||
const workbook = new ExcelJS.Workbook();
|
const workbook = new ExcelJS.Workbook();
|
||||||
await workbook.xlsx.load(response.rawPayload);
|
await workbook.xlsx.load(response.rawPayload);
|
||||||
const responses = workbook.getWorksheet('Responses');
|
const responses = workbook.getWorksheet('Responses');
|
||||||
const headers = responses.getRow(1).values.slice(1);
|
const headings = responses.getRow(1).values.slice(1);
|
||||||
expect(headers).toContain(
|
const optionLabels = responses.getRow(2).values;
|
||||||
|
const data = responses.getRow(3);
|
||||||
|
expect(headings).toContain(
|
||||||
'Prescribing % - Extended-Dose Pack — Rotacap (pack of 60) - COPD',
|
'Prescribing % - Extended-Dose Pack — Rotacap (pack of 60) - COPD',
|
||||||
);
|
);
|
||||||
const column =
|
const column =
|
||||||
headers.indexOf('Prescribing % - Extended-Dose Pack — Rotacap (pack of 60) - COPD') + 1;
|
headings.indexOf('Prescribing % - Extended-Dose Pack — Rotacap (pack of 60) - COPD') + 1;
|
||||||
expect(responses.getRow(2).getCell(column).value).toBe(25);
|
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 questions = workbook.getWorksheet('Questions');
|
||||||
const questionIds = questions.getColumn(1).values;
|
const questionIds = questions.getColumn(1).values;
|
||||||
expect(questionIds).toContain('pack_split');
|
expect(questionIds).toContain('pack_split');
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Adherence Pack Study: Cursor Prompt Pack
|
# Extended-Dose Pack Study: Cursor Prompt Pack
|
||||||
|
|
||||||
## How to use this file
|
## 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
|
alwaysApply: true
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap"
|
href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap"
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
/>
|
/>
|
||||||
<title>Adherence Pack Study</title>
|
<title>Extended-Dose Pack Study</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ export default function SiteHeader() {
|
||||||
<header className="site-header">
|
<header className="site-header">
|
||||||
<Link to="/" className="site-brand">
|
<Link to="/" className="site-brand">
|
||||||
<img src={logo} alt="CREST" />
|
<img src={logo} alt="CREST" />
|
||||||
<span>Adherence Pack Study</span>
|
<span>Extended-Dose Pack Study</span>
|
||||||
</Link>
|
</Link>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,16 @@
|
||||||
import { useState } from 'react';
|
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 [from, setFrom] = useState('');
|
||||||
const [to, setTo] = useState('');
|
const [to, setTo] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
@ -10,7 +20,7 @@ export default function ReportsPage() {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setError('');
|
setError('');
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
const params = new URLSearchParams({ status: 'COMPLETED' });
|
const params = new URLSearchParams(baseParams);
|
||||||
|
|
||||||
if (from) {
|
if (from) {
|
||||||
params.set('from', from);
|
params.set('from', from);
|
||||||
|
|
@ -21,7 +31,7 @@ export default function ReportsPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/admin/survey/export?${params}`, {
|
const response = await fetch(`${endpoint}?${params}`, {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -39,7 +49,7 @@ export default function ReportsPage() {
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.href = url;
|
link.href = url;
|
||||||
link.download = 'survey-responses.xlsx';
|
link.download = filename;
|
||||||
link.click();
|
link.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
} catch (downloadError) {
|
} 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 (
|
return (
|
||||||
<div className="max-w-xl">
|
<div className="max-w-xl">
|
||||||
<h1 className="admin-title">Reports</h1>
|
<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">
|
<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>
|
</p>
|
||||||
<form className="admin-card grid gap-3" onSubmit={download}>
|
<div className="grid gap-4">
|
||||||
<label className="grid gap-1 text-sm font-medium text-stone-600" htmlFor="export-from">
|
<ExportCard
|
||||||
From
|
id="survey-export"
|
||||||
<input
|
title="Export Survey Data"
|
||||||
id="export-from"
|
description="Completed survey responses, filtered by completion date."
|
||||||
type="date"
|
endpoint="/api/admin/survey/export"
|
||||||
className="admin-input"
|
filename="survey-responses.xlsx"
|
||||||
value={from}
|
baseParams={{ status: 'COMPLETED' }}
|
||||||
onChange={(event) => setFrom(event.target.value)}
|
/>
|
||||||
/>
|
<ExportCard
|
||||||
</label>
|
id="applications-export"
|
||||||
<label className="grid gap-1 text-sm font-medium text-stone-600" htmlFor="export-to">
|
title="Export Applications with consent"
|
||||||
To
|
description="Applications with consent and survey status, filtered by application created date. Includes full PAN and bank account numbers."
|
||||||
<input
|
endpoint="/api/admin/applications/export"
|
||||||
id="export-to"
|
filename="applications-consent.xlsx"
|
||||||
type="date"
|
/>
|
||||||
className="admin-input"
|
</div>
|
||||||
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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' }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -735,6 +735,8 @@ a:focus-visible {
|
||||||
|
|
||||||
.survey-total.warn {
|
.survey-total.warn {
|
||||||
color: #b45309;
|
color: #b45309;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1.35;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cell-label,
|
.cell-label,
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@ export default function PrivacyPage() {
|
||||||
How Chest Research and Training Pvt. Ltd. uses information for this study.
|
How Chest Research and Training Pvt. Ltd. uses information for this study.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Chest Research and Training Pvt. Ltd. uses the personal information you provide for the Adherence Pack
|
Chest Research and Training Pvt. Ltd. uses the personal information you provide for the
|
||||||
Study, for payments, for statutory compliance, and for audit.
|
Extended-Dose Pack Study, for payments, for statutory compliance, and for audit.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
That includes your name, contact and registration details, PAN, bank account, signature,
|
That includes your name, contact and registration details, PAN, bank account, signature,
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,62 @@ function formatAnswer(question, value) {
|
||||||
return '—';
|
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
|
* @param {{ messages: string[] }} props
|
||||||
*/
|
*/
|
||||||
|
|
@ -147,8 +203,13 @@ function QuestionField({
|
||||||
max={question.max}
|
max={question.max}
|
||||||
aria-invalid={invalid || undefined}
|
aria-invalid={invalid || undefined}
|
||||||
value={value === undefined || value === null ? '' : value}
|
value={value === undefined || value === null ? '' : value}
|
||||||
|
onKeyDown={rangeKeyDown(question)}
|
||||||
|
onWheel={blurOnWheel}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const raw = event.target.value;
|
const raw = event.target.value;
|
||||||
|
if (!inRange(question, raw)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
onChange(raw === '' ? '' : Number(raw));
|
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') {
|
if (question.type === 'single_choice' || question.type === 'multi_choice') {
|
||||||
const current = value && typeof value === 'object' ? value : {};
|
const current = value && typeof value === 'object' ? value : {};
|
||||||
return (
|
return (
|
||||||
|
|
@ -241,8 +328,13 @@ function QuestionField({
|
||||||
aria-invalid={invalid || undefined}
|
aria-invalid={invalid || undefined}
|
||||||
aria-label={`${row.label}, ${column.label}`}
|
aria-label={`${row.label}, ${column.label}`}
|
||||||
value={grid[row.id]?.[column.id] ?? ''}
|
value={grid[row.id]?.[column.id] ?? ''}
|
||||||
|
onKeyDown={rangeKeyDown(question)}
|
||||||
|
onWheel={blurOnWheel}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const raw = event.target.value;
|
const raw = event.target.value;
|
||||||
|
if (!inRange(question, raw)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
onChange({
|
onChange({
|
||||||
...grid,
|
...grid,
|
||||||
[row.id]: {
|
[row.id]: {
|
||||||
|
|
|
||||||
|
|
@ -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();
|
renderSurvey();
|
||||||
const field = await screen.findByLabelText(PROPORTION);
|
const field = await screen.findByLabelText(PROPORTION);
|
||||||
fireEvent.change(field, { target: { value: '150' } });
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
|
|
||||||
|
|
||||||
expect(
|
fireEvent.change(field, { target: { value: '40' } });
|
||||||
await screen.findByText((text) => text.includes(`${PROPORTION} must be at most 100`)),
|
expect(field).toHaveValue(40);
|
||||||
).toBeInTheDocument();
|
|
||||||
expect(screen.getByRole('heading', { name: 'Foracort prescribing pattern' })).toBeInTheDocument();
|
fireEvent.change(field, { target: { value: '150' } });
|
||||||
expect(screen.queryByRole('heading', { name: 'Notes' })).not.toBeInTheDocument();
|
expect(field).toHaveValue(40);
|
||||||
expect(screen.queryByRole('heading', { name: 'Review' })).not.toBeInTheDocument();
|
|
||||||
|
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 () => {
|
it('keeps a missing required answer on the current step', async () => {
|
||||||
|
|
@ -119,4 +171,43 @@ describe('survey steps', () => {
|
||||||
expect.objectContaining({ method: 'PUT' }),
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ export default function UserLayout() {
|
||||||
<div className="user-topbar-inner">
|
<div className="user-topbar-inner">
|
||||||
<Link to="/dashboard" className="user-brand">
|
<Link to="/dashboard" className="user-brand">
|
||||||
<img src={logo} alt="CREST" />
|
<img src={logo} alt="CREST" />
|
||||||
<span>Adherence Pack Study</span>
|
<span>Extended-Dose Pack Study</span>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="user-menu" ref={menuRef}>
|
<div className="user-menu" ref={menuRef}>
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,10 @@
|
||||||
- **Name:** ______
|
- **Name:** ______
|
||||||
- **Qualification** (single choice): MBBS / MD (Chest) / DM (Pulmo / DNB / DCH) / MD (Med) / DNB (Med) / Any other (specify): ______
|
- **Qualification** (single choice): MBBS / MD (Chest) / DM (Pulmo / DNB / DCH) / MD (Med) / DNB (Med) / Any other (specify): ______
|
||||||
- **Years of Practice:** ______
|
- **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): ______
|
- **Specialization** (single choice): Chest Physician / Consulting Physician / Other (specify): ______
|
||||||
- **City:** ______
|
- **City:** ______
|
||||||
- **State:** ______
|
- **State** (dropdown): all Indian states and union territories
|
||||||
|
|
||||||
## Section 2: Physician Practice Profile
|
## Section 2: Physician Practice Profile
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue