- In Survey Export, added seperate columns for each option of the field Practice Setting.

- Added downloadable PDF version of the consent form for Admin and User.
This commit is contained in:
Amey Bhagwat 2026-10-28 10:51:50 +05:30
parent 125e679019
commit 6d1b72024f
16 changed files with 755 additions and 3 deletions

View File

@ -0,0 +1,18 @@
/**
* Stored PDF of the PIS/ICF a physician consented to.
* @param {import('knex').Knex} knex
*/
export async function up(knex) {
await knex.schema.alterTable('consents', (table) => {
table.string('consent_pdf_key', 512).nullable().after('answers');
});
}
/**
* @param {import('knex').Knex} knex
*/
export async function down(knex) {
await knex.schema.alterTable('consents', (table) => {
table.dropColumn('consent_pdf_key');
});
}

View File

@ -0,0 +1,190 @@
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { CONSENT_STATEMENTS } from '../schemas/consent.js';
import { escapeHtml } from './agreementHtml.js';
const PIS_PATH = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'../../../files/docs/pis.md',
);
const pisSource = readFileSync(PIS_PATH, 'utf8');
/** Needs a font with the ₹ sign; Noto Sans or DejaVu Sans on Linux servers. */
const FONT_STACK = 'Calibri, Carlito, "Noto Sans", "DejaVu Sans", Arial, sans-serif';
/** The PIS section that starts page 2 in the original document. */
const PAGE_TWO_HEADING = 'What will you be expected to do?';
/**
* @param {string} source
*/
function documentMeta(source) {
const line = source.split('\n').find((item) => item.startsWith('**Document:**')) ?? '';
const version = /\*\*Document:\*\*\s*([^|]+)/.exec(line)?.[1]?.trim() || 'PIS and ICF V01';
const date = /\*\*Date:\*\*\s*([^|]+)/.exec(line)?.[1]?.trim() || '';
return { version, date };
}
/**
* @param {string} text
*/
function inline(text) {
return escapeHtml(text).replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
}
/**
* Lines ending in two spaces are hard line breaks, as in Markdown.
* @param {string[]} lines
*/
function paragraph(lines) {
return lines
.map((line, index) => {
const content = inline(line.trimEnd());
const last = index === lines.length - 1;
return last ? content : `${content}${/ {2}$/.test(line) ? '<br />' : ' '}`;
})
.join('');
}
/**
* Render `files/docs/pis.md` the way the original PIS lays it out: the
* document line becomes the page header, "Survey Title" runs inline with the
* title, and the "Background" label is not shown.
* @param {string} source
*/
export function pisBodyHtml(source = pisSource) {
const blocks = source
.split(/\n\s*\n/)
.map((block) => block.split('\n').filter((line) => line.trim() !== ''))
.filter((lines) => lines.length > 0);
/** @type {string[]} */
const html = [];
let surveyTitle = false;
for (const lines of blocks) {
const first = lines[0].trim();
if (first.startsWith('# ') || first.startsWith('**Document:**')) {
continue;
}
if (first.startsWith('## ')) {
const heading = first.slice(3).trim();
if (heading === 'Survey Title') {
surveyTitle = true;
} else if (heading !== 'Background') {
const pageBreak = heading === PAGE_TWO_HEADING ? ' class="page-break"' : '';
html.push(`<h3${pageBreak}>${inline(heading)}</h3>`);
}
continue;
}
if (lines.every((line) => line.trim().startsWith('- '))) {
const items = lines.map((line) => `<li>${inline(line.trim().slice(2))}</li>`).join('');
html.push(`<ul>${items}</ul>`);
continue;
}
if (surveyTitle) {
html.push(`<p class="survey-title"><strong>Survey Title:</strong> ${paragraph(lines)}</p>`);
surveyTitle = false;
continue;
}
html.push(`<p>${paragraph(lines)}</p>`);
}
return html.join('\n');
}
/**
* @param {unknown} iso
*/
function displayDate(iso) {
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(iso ?? ''));
return match ? `${match[3]}/${match[2]}/${match[1]}` : '';
}
/**
* @param {{ participantName: string, consentDate: string, answers: Record<string, unknown> }} consent
*/
function icfHtml(consent) {
const rows = CONSENT_STATEMENTS.map((statement) => {
const checked = consent.answers?.[statement.id] === true;
const label = checked ? 'Accepted' : 'Not accepted';
return `<tr>
<td class="box-cell"><span class="box${checked ? ' checked' : ''}" role="img" aria-label="${label}"></span></td>
<td>${escapeHtml(statement.label)}</td>
</tr>`;
}).join('\n');
return `<section class="icf">
<p class="doc-title">ONLINE<br />INFORMED CONSENT FORM (ICF)</p>
<p>I have read and understood the information provided above regarding this survey. I have had the opportunity to ask questions and have received satisfactory answers to questions.</p>
<table class="statements">
${rows}
</table>
<p class="field">Participant Name: <span class="filled">${escapeHtml(consent.participantName)}</span></p>
<p class="field">Date: <span class="filled">${escapeHtml(displayDate(consent.consentDate))}</span></p>
</section>`;
}
/**
* @param {string} value
*/
function cssString(value) {
return `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
}
/**
* The PIS and the completed ICF as submitted online, without a signature line.
* @param {{ participantName: string, consentDate: string, answers: Record<string, unknown> }} consent
*/
export function consentHtml(consent) {
const meta = documentMeta(pisSource);
const headerLeft = meta.date
? `${cssString(meta.version)} "\\A" ${cssString(`Date: ${meta.date}`)}`
: cssString(meta.version);
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>${escapeHtml(meta.version)} - Participant Information Sheet and Informed Consent Form</title>
<style>
@page {
size: A4;
margin: 26mm 20mm 20mm;
@top-left { content: ${headerLeft}; white-space: pre; font-family: ${FONT_STACK}; font-size: 9pt; color: #44403c; }
@bottom-left { content: "CREST Pvt. Ltd."; font-family: ${FONT_STACK}; font-size: 9pt; color: #44403c; }
@bottom-center { content: "Page " counter(page) " of " counter(pages); font-family: ${FONT_STACK}; font-size: 9pt; color: #44403c; }
@bottom-right { content: "Confidential"; font-family: ${FONT_STACK}; font-size: 9pt; color: #44403c; }
}
body { font-family: ${FONT_STACK}; font-size: 11pt; line-height: 1.4; color: #1c1917; }
p { margin: 0 0 0.7em; }
h3 { font-size: 11.5pt; margin: 1em 0 0.35em; }
ul { margin: 0 0 0.7em; padding-left: 1.4em; }
li { margin-bottom: 0.2em; }
.doc-title { text-align: center; font-weight: 700; font-size: 13pt; margin-bottom: 1em; }
.page-break { break-before: page; margin-top: 0; }
.icf { break-before: page; }
table.statements { border-collapse: collapse; margin: 0.4em 0 1.2em; }
table.statements td { vertical-align: top; padding: 0.25em 0; }
td.box-cell { width: 1.6em; }
.box { display: inline-block; position: relative; width: 10pt; height: 10pt; margin-top: 2pt; border: 1.2pt solid #1c1917; }
.box.checked::after { content: ""; position: absolute; left: 3pt; top: 0.4pt; width: 2.6pt; height: 6pt; border: solid #1c1917; border-width: 0 1.5pt 1.5pt 0; transform: rotate(45deg); }
.field { margin: 0 0 0.6em; }
.filled { font-weight: 700; text-decoration: underline; text-underline-offset: 0.15em; }
</style>
</head>
<body>
<p class="doc-title">ONLINE<br />PARTICIPANT INFORMATION SHEET (PIS)</p>
${pisBodyHtml()}
${icfHtml(consent)}
</body>
</html>`;
}

View File

@ -2,7 +2,9 @@ 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 { db } from '../db/knex.js';
import { exportApplicationsWorkbook } from '../services/applicationsExport.js'; import { exportApplicationsWorkbook } from '../services/applicationsExport.js';
import { consentPdfFor, pdfDownload } from '../services/consentPdf.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 {
@ -95,6 +97,25 @@ export const adminRoutes = {
}, },
}); });
server.route({
method: 'GET',
path: '/api/admin/applications/{id}/consent-pdf',
options: { pre },
async handler(request, h) {
const applicationId = numericId(request);
const consent = await db('consents').where({ application_id: applicationId }).first();
const file = await consentPdfFor(consent);
await auditFromRequest(request, {
actorType: 'ADMIN',
actorId: request.pre.admin.id,
action: 'CONSENT_PDF_DOWNLOADED',
entityType: 'applications',
entityId: applicationId,
});
return pdfDownload(h, file);
},
});
server.route({ server.route({
method: 'POST', method: 'POST',
path: '/api/admin/applications/{id}/approve', path: '/api/admin/applications/{id}/approve',

View File

@ -1,5 +1,7 @@
import { db } from '../db/knex.js';
import { requireUser } from '../middleware/requireUser.js'; import { requireUser } from '../middleware/requireUser.js';
import { getConsentForUser, submitConsent } from '../services/consent.js'; import { getConsentForUser, submitConsent } from '../services/consent.js';
import { consentPdfFor, pdfDownload } from '../services/consentPdf.js';
/** @type {import('@hapi/hapi').Plugin<void>} */ /** @type {import('@hapi/hapi').Plugin<void>} */
export const consentRoutes = { export const consentRoutes = {
@ -17,6 +19,21 @@ export const consentRoutes = {
}, },
}); });
server.route({
method: 'GET',
path: '/api/consent/me/pdf',
options: {
auth: false,
pre: [requireUser],
},
async handler(request, h) {
const consent = await db('consents')
.where({ user_id: Number(request.pre.user.id) })
.first();
return pdfDownload(h, await consentPdfFor(consent));
},
});
server.route({ server.route({
method: 'POST', method: 'POST',
path: '/api/consent', path: '/api/consent',

View File

@ -281,6 +281,7 @@ export async function getApplication(id) {
.orderBy('id', 'desc') .orderBy('id', 'desc')
.first(); .first();
const pdfKey = application.final_pdf_key || application.signed_pdf_key; const pdfKey = application.final_pdf_key || application.signed_pdf_key;
const consent = await db('consents').where({ application_id: id }).first();
const emails = await db('email_log') const emails = await db('email_log')
.where({ application_id: id }) .where({ application_id: id })
.orderBy('created_at', 'asc') .orderBy('created_at', 'asc')
@ -299,6 +300,13 @@ export async function getApplication(id) {
userAgent: signature.user_agent, userAgent: signature.user_agent,
} }
: null, : null,
consent: consent
? {
participantName: consent.participant_name,
consentDate: String(consent.consent_date ?? '').slice(0, 10),
submittedAt: consent.submitted_at,
}
: null,
pdfUrl: pdfKey ? await getPresignedUrl(String(pdfKey)) : null, pdfUrl: pdfKey ? await getPresignedUrl(String(pdfKey)) : null,
emails, emails,
audit: audit.map((row) => ({ audit: audit.map((row) => ({

View File

@ -2,6 +2,7 @@ import Boom from '@hapi/boom';
import { db } from '../db/knex.js'; import { db } from '../db/knex.js';
import { auditFromRequest } from '../middleware/audit.js'; import { auditFromRequest } from '../middleware/audit.js';
import { submitConsentSchema } from '../schemas/consent.js'; import { submitConsentSchema } from '../schemas/consent.js';
import { storeConsentPdf } from './consentPdf.js';
/** /**
* @param {unknown} value * @param {unknown} value
@ -90,5 +91,12 @@ export async function submitConsent(request, userId, payload) {
}); });
const row = await db('consents').where({ id }).first(); const row = await db('consents').where({ id }).first();
try {
await storeConsentPdf(row, application);
} catch (error) {
request.log(['warn', 'consent-pdf'], error);
}
return presentConsent(row); return presentConsent(row);
} }

View File

@ -0,0 +1,92 @@
import Boom from '@hapi/boom';
import { db } from '../db/knex.js';
import { consentHtml } from '../pdf/consentHtml.js';
import { renderPdf } from '../pdf/render.js';
import { getObject, putObject } from './storage.js';
/**
* @param {unknown} value
*/
function parseJson(value) {
if (typeof value === 'string') {
return JSON.parse(value);
}
return value ?? null;
}
/**
* @param {unknown} value
*/
function isoDate(value) {
if (typeof value === 'string') {
return value.slice(0, 10);
}
return value ? new Date(String(value)).toISOString().slice(0, 10) : '';
}
/**
* @param {Record<string, unknown>} application
*/
export function consentPdfFilename(application) {
const reference = String(application.registration_no || application.id || 'consent')
.replace(/[^A-Za-z0-9._-]+/g, '-')
.replace(/^-+|-+$/g, '');
return `consent-${reference || 'form'}.pdf`;
}
/**
* Render the consent once and keep the stored file as the record, so later
* edits to the PIS text do not change what a physician agreed to.
* @param {Record<string, unknown>} consent
* @param {Record<string, unknown>} application
* @returns {Promise<Buffer>}
*/
export async function storeConsentPdf(consent, application) {
const pdf = await renderPdf(
consentHtml({
participantName: String(consent.participant_name ?? ''),
consentDate: isoDate(consent.consent_date),
answers: parseJson(consent.answers) ?? {},
}),
);
const key = `applications/${application.public_id}/consent.pdf`;
await putObject(key, pdf, 'application/pdf');
await db('consents').where({ id: consent.id }).update({ consent_pdf_key: key });
return pdf;
}
/**
* Consents submitted before PDFs existed get theirs on first download.
* @param {Record<string, unknown> | undefined} consent
* @returns {Promise<{ pdf: Buffer, filename: string }>}
*/
export async function consentPdfFor(consent) {
if (!consent) {
throw Boom.notFound('Consent has not been submitted');
}
const application = await db('applications').where({ id: consent.application_id }).first();
if (!application) {
throw Boom.notFound('Application not found');
}
const pdf = consent.consent_pdf_key
? await getObject(String(consent.consent_pdf_key))
: await storeConsentPdf(consent, application);
return { pdf, filename: consentPdfFilename(application) };
}
/**
* @param {import('@hapi/hapi').ResponseToolkit} h
* @param {{ pdf: Buffer, filename: string }} file
*/
export function pdfDownload(h, file) {
return h
.response(file.pdf)
.type('application/pdf')
.header('content-disposition', `attachment; filename="${file.filename}"`)
.header('cache-control', 'no-store');
}

View File

@ -13,7 +13,12 @@ const FIXED = [
]; ];
/** Multi-choice questions exported as one "Yes" column per option. */ /** Multi-choice questions exported as one "Yes" column per option. */
const SPLIT_QUESTIONS = new Set(['barriers', 'benefit']); const SPLIT_QUESTIONS = new Set(['practice_setting', 'barriers', 'benefit']);
/** Row-1 headings that replace the default "section title: question" text. */
const SPLIT_HEADINGS = {
practice_setting: 'Practice Setting',
};
/** /**
* @param {Record<string, any>} question * @param {Record<string, any>} question
@ -94,7 +99,7 @@ export function answerColumns(definition) {
} }
if (isSplit(question)) { if (isSplit(question)) {
const group = `${context}: ${question.label}`; const group = SPLIT_HEADINGS[question.id] ?? `${context}: ${question.label}`;
for (const option of question.options) { for (const option of question.options) {
columns.push({ columns.push({

View File

@ -1,10 +1,18 @@
import { afterEach, describe, expect, it } from 'vitest'; import argon2 from 'argon2';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createServer } from '../src/app.js'; import { createServer } from '../src/app.js';
import { db } from '../src/db/knex.js'; import { db } from '../src/db/knex.js';
import { renderPdf } from '../src/pdf/render.js';
import { clearOutbox, outbox } from '../src/services/mail.js'; import { clearOutbox, outbox } from '../src/services/mail.js';
import { truncateTables } from './truncate.js'; import { truncateTables } from './truncate.js';
vi.mock('../src/pdf/render.js', () => ({
renderPdf: vi.fn(async () => Buffer.from('%PDF-1.4 consent')),
}));
const email = 'consent-doctor@example.com'; const email = 'consent-doctor@example.com';
const adminEmail = 'consent-admin@example.com';
const adminPassword = 'correct-password';
const answers = { const answers = {
voluntary_withdraw: true, voluntary_withdraw: true,
@ -34,6 +42,7 @@ describe('consent', () => {
afterEach(async () => { afterEach(async () => {
clearOutbox(); clearOutbox();
vi.mocked(renderPdf).mockClear();
await server?.stop(); await server?.stop();
await truncateTables(); await truncateTables();
}); });
@ -129,4 +138,117 @@ describe('consent', () => {
expect(response.statusCode).toBe(400); expect(response.statusCode).toBe(400);
}); });
async function adminCookie(api) {
await db('admin_users').insert({
name: 'Administrator',
email: adminEmail,
password_hash: await argon2.hash(adminPassword, { type: argon2.argon2id }),
is_active: true,
});
const response = await api.inject({
method: 'POST',
url: '/api/admin/auth/login',
payload: { email: adminEmail, password: adminPassword },
});
const raw = response.headers['set-cookie'];
const header = Array.isArray(raw) ? raw[0] : raw;
return header.split(';')[0];
}
async function submit(api, cookie) {
return api.inject({
method: 'POST',
url: '/api/consent',
headers: { cookie },
payload: { participant_name: 'Dr Consent Test', consent_date: '2026-10-16', answers },
});
}
it('stores a consent PDF on submission and lets the physician download it', async () => {
const api = await start();
const cookie = await login(api);
const missing = await api.inject({
method: 'GET',
url: '/api/consent/me/pdf',
headers: { cookie },
});
expect(missing.statusCode).toBe(404);
expect((await submit(api, cookie)).statusCode).toBe(200);
const row = await db('consents').where({ participant_name: 'Dr Consent Test' }).first();
expect(row.consent_pdf_key).toMatch(/\/consent\.pdf$/);
expect(renderPdf).toHaveBeenCalledTimes(1);
const html = String(vi.mocked(renderPdf).mock.calls[0][0]);
expect(html).toContain('Dr Consent Test');
expect(html).toContain('16/10/2026');
expect(html).toContain('INFORMED CONSENT FORM (ICF)');
expect(html).not.toContain('Electronic Signature');
const download = await api.inject({
method: 'GET',
url: '/api/consent/me/pdf',
headers: { cookie },
});
expect(download.statusCode).toBe(200);
expect(download.headers['content-type']).toContain('application/pdf');
expect(download.headers['content-disposition']).toMatch(/attachment; filename="consent-.+\.pdf"/);
expect(download.rawPayload.toString()).toBe('%PDF-1.4 consent');
expect(renderPdf).toHaveBeenCalledTimes(1);
});
it('requires a signed-in physician to download a consent', async () => {
const api = await start();
const response = await api.inject({ method: 'GET', url: '/api/consent/me/pdf' });
expect(response.statusCode).toBe(401);
});
it('lets an admin download a consent and records it', async () => {
const api = await start();
const cookie = await login(api);
await submit(api, cookie);
const row = await db('consents').where({ participant_name: 'Dr Consent Test' }).first();
await db('consents').where({ id: row.id }).update({ consent_pdf_key: null });
const admin = await adminCookie(api);
const detail = await api.inject({
method: 'GET',
url: `/api/admin/applications/${row.application_id}`,
headers: { cookie: admin },
});
expect(detail.result.consent).toMatchObject({
participantName: 'Dr Consent Test',
consentDate: '2026-10-16',
});
const download = await api.inject({
method: 'GET',
url: `/api/admin/applications/${row.application_id}/consent-pdf`,
headers: { cookie: admin },
});
expect(download.statusCode).toBe(200);
expect(download.headers['content-type']).toContain('application/pdf');
expect((await db('consents').where({ id: row.id }).first()).consent_pdf_key).toBeTruthy();
expect(
await db('audit_log')
.where({ action: 'CONSENT_PDF_DOWNLOADED', entity_id: row.application_id })
.first(),
).toBeTruthy();
});
it('returns 404 to an admin when consent has not been submitted', async () => {
const api = await start();
await login(api);
const application = await db('applications').where({ email }).first();
const admin = await adminCookie(api);
const response = await api.inject({
method: 'GET',
url: `/api/admin/applications/${application.id}/consent-pdf`,
headers: { cookie: admin },
});
expect(response.statusCode).toBe(404);
});
}); });

View File

@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';
import { consentHtml, pisBodyHtml } from '../src/pdf/consentHtml.js';
import { CONSENT_STATEMENTS } from '../src/schemas/consent.js';
const allAccepted = Object.fromEntries(CONSENT_STATEMENTS.map((statement) => [statement.id, true]));
describe('consent PDF html', () => {
it('fills in the participant and ticks every accepted statement', () => {
const html = consentHtml({
participantName: 'Dr <Asha> Rao',
consentDate: '2026-10-27',
answers: allAccepted,
});
expect(html).toContain('PARTICIPANT INFORMATION SHEET (PIS)');
expect(html).toContain('INFORMED CONSENT FORM (ICF)');
expect(html).toContain('<span class="filled">Dr &lt;Asha&gt; Rao</span>');
expect(html).toContain('<span class="filled">27/10/2026</span>');
expect(html.match(/class="box checked"/g)).toHaveLength(CONSENT_STATEMENTS.length);
for (const statement of CONSENT_STATEMENTS) {
expect(html).toContain(statement.label);
}
});
it('leaves out the signature line and the web form button', () => {
const html = consentHtml({ participantName: 'A', consentDate: '2026-10-27', answers: {} });
expect(html).not.toMatch(/Electronic Signature/i);
expect(html).not.toMatch(/SUBMIT YOUR CONSENT/i);
expect(html).not.toContain('class="box checked"');
});
it('uses the PIS document line as the page header', () => {
const html = consentHtml({ participantName: 'A', consentDate: '2026-10-27', answers: {} });
expect(html).toContain('@top-left { content: "PIS and ICF V01" "\\A" "Date: 14/10/2026"');
expect(html).toContain('counter(page) " of " counter(pages)');
expect(html).not.toContain('**Document:**');
});
it('starts page two at the same section as the original document', () => {
const body = pisBodyHtml();
expect(body).toContain('<h3 class="page-break">What will you be expected to do?</h3>');
expect(body).toContain('<strong>Survey Title:</strong>');
expect(body).not.toContain('Background');
});
});

View File

@ -60,6 +60,7 @@ describe('survey export', () => {
answers: JSON.stringify({ answers: JSON.stringify({
name: 'Dr Asha Rao', name: 'Dr Asha Rao',
adherence_comparison: { choice: 'much_better' }, adherence_comparison: { choice: 'much_better' },
practice_setting: { choices: ['private_clinic', 'govt_hospital'] },
barriers: { choices: ['cost', 'other'], other: 'Lack of time' }, barriers: { choices: ['cost', 'other'], other: 'Lack of time' },
benefit: { choices: ['elderly'] }, benefit: { choices: ['elderly'] },
pack_split: { pack_split: {
@ -113,6 +114,18 @@ describe('survey export', () => {
'Patient Characteristics Associated with Adherence: Which patients, in your opinion, are most likely to benefit from an Extended-dose pack?', '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'); expect(data.getCell(elderly).value).toBe('Yes');
const settingStart = headings.indexOf('Practice Setting') + 1;
expect(settingStart).toBeGreaterThan(0);
expect(responses.getCell(1, settingStart + 2).isMerged).toBe(true);
expect(optionLabels.slice(settingStart, settingStart + 3)).toEqual([
'Private Clinic',
'Private Hospital',
'Govt. Hospital',
]);
expect(data.getCell(settingStart).value).toBe('Yes');
expect(data.getCell(settingStart + 1).value).toBeNull();
expect(data.getCell(settingStart + 2).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');

View File

@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { downloadFile } from '../downloadFile.js';
import { adminApi } from './adminApi.js'; import { adminApi } from './adminApi.js';
import Modal from './Modal.jsx'; import Modal from './Modal.jsx';
import StatusBadge from './StatusBadge.jsx'; import StatusBadge from './StatusBadge.jsx';
@ -29,6 +30,8 @@ export default function ApplicationDetailPage() {
const [rejecting, setRejecting] = useState(false); const [rejecting, setRejecting] = useState(false);
const [reason, setReason] = useState(''); const [reason, setReason] = useState('');
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [consentBusy, setConsentBusy] = useState(false);
const [consentError, setConsentError] = useState('');
useEffect(() => { useEffect(() => {
adminApi(`/api/admin/applications/${id}`) adminApi(`/api/admin/applications/${id}`)
@ -36,6 +39,22 @@ export default function ApplicationDetailPage() {
.catch((loadError) => setError(loadError.message)); .catch((loadError) => setError(loadError.message));
}, [id]); }, [id]);
async function downloadConsent() {
setConsentBusy(true);
setConsentError('');
try {
await downloadFile(`/api/admin/applications/${id}/consent-pdf`, {
fallbackName: 'consent.pdf',
loginPath: '/admin/login',
});
} catch (downloadError) {
setConsentError(downloadError.message);
} finally {
setConsentBusy(false);
}
}
async function resendSurveyLink() { async function resendSurveyLink() {
setBusy(true); setBusy(true);
setError(''); setError('');
@ -125,6 +144,32 @@ export default function ApplicationDetailPage() {
) : ( ) : (
<p className="text-sm">No signature</p> <p className="text-sm">No signature</p>
)} )}
<h2 className="mt-6 font-semibold text-[#134e4a]">Consent</h2>
{application.consent ? (
<>
<p className="text-sm">
{application.consent.participantName} · consent date{' '}
{application.consent.consentDate}
<br />
Submitted at {application.consent.submittedAt}
</p>
<button
type="button"
className="admin-btn admin-btn-primary mt-2 text-sm"
disabled={consentBusy}
onClick={downloadConsent}
>
{consentBusy ? 'Preparing…' : 'Download Consent'}
</button>
{consentError ? (
<p className="admin-card-status admin-card-status-error" role="alert">
{consentError}
</p>
) : null}
</>
) : (
<p className="text-sm">Consent not submitted</p>
)}
{canDecide ? ( {canDecide ? (
<div className="mt-4 flex gap-2"> <div className="mt-4 flex gap-2">
<button <button

View File

@ -0,0 +1,35 @@
/**
* @param {string | null} header
*/
function filenameFrom(header) {
const match = /filename="?([^";]+)"?/i.exec(header ?? '');
return match ? match[1] : null;
}
/**
* Download a cookie-authenticated file, keeping the server's filename.
* @param {string} path
* @param {{ fallbackName: string, loginPath: string }} options
*/
export async function downloadFile(path, options) {
const response = await fetch(path, { credentials: 'include' });
if (response.status === 401) {
window.location.assign(options.loginPath);
return;
}
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data?.error?.message || 'Download failed');
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download =
filenameFrom(response.headers.get('content-disposition')) || options.fallbackName;
link.click();
URL.revokeObjectURL(url);
}

View File

@ -1104,6 +1104,16 @@ a:focus-visible {
background: #f0fdfa; background: #f0fdfa;
} }
.site .user-step .user-step-action {
margin-top: auto;
padding: 0.45rem 0.85rem;
font-size: 0.85rem;
}
.user-step .field-error {
margin: 0;
}
.user-step-status { .user-step-status {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;

View File

@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Link, useOutletContext } from 'react-router-dom'; import { Link, useOutletContext } from 'react-router-dom';
import { api } from '../api.js'; import { api } from '../api.js';
import { downloadFile } from '../downloadFile.js';
import { pathForNextStep, userApi } from '../userApi.js'; import { pathForNextStep, userApi } from '../userApi.js';
/** /**
@ -208,6 +209,8 @@ export default function UserDashboardPage() {
const me = useOutletContext(); const me = useOutletContext();
const [surveyError, setSurveyError] = useState(''); const [surveyError, setSurveyError] = useState('');
const [surveyBusy, setSurveyBusy] = useState(false); const [surveyBusy, setSurveyBusy] = useState(false);
const [consentBusy, setConsentBusy] = useState(false);
const [consentError, setConsentError] = useState('');
const [previewUrl, setPreviewUrl] = useState(''); const [previewUrl, setPreviewUrl] = useState('');
const [finalPdfUrl, setFinalPdfUrl] = useState(''); const [finalPdfUrl, setFinalPdfUrl] = useState('');
const [chequeUrl, setChequeUrl] = useState(''); const [chequeUrl, setChequeUrl] = useState('');
@ -252,6 +255,22 @@ export default function UserDashboardPage() {
}; };
}, [me.application]); }, [me.application]);
async function downloadConsent() {
setConsentBusy(true);
setConsentError('');
try {
await downloadFile('/api/consent/me/pdf', {
fallbackName: 'consent.pdf',
loginPath: '/login',
});
} catch (error) {
setConsentError(error.message);
} finally {
setConsentBusy(false);
}
}
async function openSurvey() { async function openSurvey() {
setSurveyBusy(true); setSurveyBusy(true);
setSurveyError(''); setSurveyError('');
@ -280,6 +299,21 @@ export default function UserDashboardPage() {
<div className={`user-step ${me.steps.consent.done ? 'done' : 'pending'}`}> <div className={`user-step ${me.steps.consent.done ? 'done' : 'pending'}`}>
<strong>1. Consent (PIS / ICF)</strong> <strong>1. Consent (PIS / ICF)</strong>
<StepStatusBadge label={me.steps.consent.done ? 'Completed' : 'Not started'} /> <StepStatusBadge label={me.steps.consent.done ? 'Completed' : 'Not started'} />
{me.steps.consent.done ? (
<button
type="button"
className="user-step-action"
onClick={downloadConsent}
disabled={consentBusy}
>
{consentBusy ? 'Preparing…' : 'Download Consent'}
</button>
) : null}
{consentError ? (
<p className="field-error" role="alert">
{consentError}
</p>
) : null}
</div> </div>
<div className={`user-step ${me.steps.agreement.done ? 'done' : 'pending'}`}> <div className={`user-step ${me.steps.agreement.done ? 'done' : 'pending'}`}>
<strong>2. Physician agreement</strong> <strong>2. Physician agreement</strong>

View File

@ -0,0 +1,86 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter, Outlet, Route, Routes } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import UserDashboardPage from './UserDashboardPage.jsx';
/**
* @param {boolean} consentDone
*/
function meWith(consentDone) {
return {
user: { email: 'asha@example.com', name: 'Asha Rao' },
application: null,
survey: null,
steps: {
consent: { done: consentDone },
agreement: { done: false },
survey: { done: false },
},
nextStep: consentDone ? 'agreement' : 'consent',
};
}
/**
* @param {ReturnType<typeof meWith>} me
*/
function renderDashboard(me) {
render(
<MemoryRouter initialEntries={['/dashboard']}>
<Routes>
<Route element={<Outlet context={me} />}>
<Route path="/dashboard" element={<UserDashboardPage />} />
</Route>
</Routes>
</MemoryRouter>,
);
}
describe('dashboard consent download', () => {
/** @type {ReturnType<typeof meWith>} */
let me;
const fetchMock = vi.fn();
beforeEach(() => {
fetchMock.mockReset();
fetchMock.mockImplementation(async (path) => {
if (String(path) === '/api/consent/me/pdf') {
return {
ok: true,
status: 200,
headers: new Headers({ 'content-disposition': 'attachment; filename="consent-MMC-1.pdf"' }),
blob: async () => new Blob(['%PDF']),
};
}
return { ok: true, status: 200, json: async () => me };
});
vi.stubGlobal('fetch', fetchMock);
URL.createObjectURL = vi.fn(() => 'blob:consent');
URL.revokeObjectURL = vi.fn();
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
it('hides the button until consent is completed', () => {
me = meWith(false);
renderDashboard(me);
expect(screen.queryByRole('button', { name: 'Download Consent' })).not.toBeInTheDocument();
});
it('downloads the consent PDF with the server filename', async () => {
me = meWith(true);
const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function () {
expect(this.download).toBe('consent-MMC-1.pdf');
});
renderDashboard(me);
fireEvent.click(screen.getByRole('button', { name: 'Download Consent' }));
await waitFor(() => expect(click).toHaveBeenCalled());
expect(fetchMock).toHaveBeenCalledWith('/api/consent/me/pdf', { credentials: 'include' });
});
});