- 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:
parent
125e679019
commit
6d1b72024f
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
|
|
@ -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>`;
|
||||
}
|
||||
|
|
@ -2,7 +2,9 @@ import Boom from '@hapi/boom';
|
|||
import { z } from 'zod';
|
||||
import { auditFromRequest } from '../middleware/audit.js';
|
||||
import { requireAdmin } from '../middleware/requireAdmin.js';
|
||||
import { db } from '../db/knex.js';
|
||||
import { exportApplicationsWorkbook } from '../services/applicationsExport.js';
|
||||
import { consentPdfFor, pdfDownload } from '../services/consentPdf.js';
|
||||
import { exportWorkbook } from '../services/surveyExport.js';
|
||||
import { getReminderSettings, saveReminderSettings } from '../services/settings.js';
|
||||
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({
|
||||
method: 'POST',
|
||||
path: '/api/admin/applications/{id}/approve',
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { db } from '../db/knex.js';
|
||||
import { requireUser } from '../middleware/requireUser.js';
|
||||
import { getConsentForUser, submitConsent } from '../services/consent.js';
|
||||
import { consentPdfFor, pdfDownload } from '../services/consentPdf.js';
|
||||
|
||||
/** @type {import('@hapi/hapi').Plugin<void>} */
|
||||
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({
|
||||
method: 'POST',
|
||||
path: '/api/consent',
|
||||
|
|
|
|||
|
|
@ -281,6 +281,7 @@ export async function getApplication(id) {
|
|||
.orderBy('id', 'desc')
|
||||
.first();
|
||||
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')
|
||||
.where({ application_id: id })
|
||||
.orderBy('created_at', 'asc')
|
||||
|
|
@ -299,6 +300,13 @@ export async function getApplication(id) {
|
|||
userAgent: signature.user_agent,
|
||||
}
|
||||
: 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,
|
||||
emails,
|
||||
audit: audit.map((row) => ({
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import Boom from '@hapi/boom';
|
|||
import { db } from '../db/knex.js';
|
||||
import { auditFromRequest } from '../middleware/audit.js';
|
||||
import { submitConsentSchema } from '../schemas/consent.js';
|
||||
import { storeConsentPdf } from './consentPdf.js';
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
|
|
@ -90,5 +91,12 @@ export async function submitConsent(request, userId, payload) {
|
|||
});
|
||||
|
||||
const row = await db('consents').where({ id }).first();
|
||||
|
||||
try {
|
||||
await storeConsentPdf(row, application);
|
||||
} catch (error) {
|
||||
request.log(['warn', 'consent-pdf'], error);
|
||||
}
|
||||
|
||||
return presentConsent(row);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
@ -13,7 +13,12 @@ const FIXED = [
|
|||
];
|
||||
|
||||
/** 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
|
||||
|
|
@ -94,7 +99,7 @@ export function answerColumns(definition) {
|
|||
}
|
||||
|
||||
if (isSplit(question)) {
|
||||
const group = `${context}: ${question.label}`;
|
||||
const group = SPLIT_HEADINGS[question.id] ?? `${context}: ${question.label}`;
|
||||
|
||||
for (const option of question.options) {
|
||||
columns.push({
|
||||
|
|
|
|||
|
|
@ -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 { db } from '../src/db/knex.js';
|
||||
import { renderPdf } from '../src/pdf/render.js';
|
||||
import { clearOutbox, outbox } from '../src/services/mail.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 adminEmail = 'consent-admin@example.com';
|
||||
const adminPassword = 'correct-password';
|
||||
|
||||
const answers = {
|
||||
voluntary_withdraw: true,
|
||||
|
|
@ -34,6 +42,7 @@ describe('consent', () => {
|
|||
|
||||
afterEach(async () => {
|
||||
clearOutbox();
|
||||
vi.mocked(renderPdf).mockClear();
|
||||
await server?.stop();
|
||||
await truncateTables();
|
||||
});
|
||||
|
|
@ -129,4 +138,117 @@ describe('consent', () => {
|
|||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 <Asha> 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -60,6 +60,7 @@ describe('survey export', () => {
|
|||
answers: JSON.stringify({
|
||||
name: 'Dr Asha Rao',
|
||||
adherence_comparison: { choice: 'much_better' },
|
||||
practice_setting: { choices: ['private_clinic', 'govt_hospital'] },
|
||||
barriers: { choices: ['cost', 'other'], other: 'Lack of time' },
|
||||
benefit: { choices: ['elderly'] },
|
||||
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?',
|
||||
);
|
||||
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 questionIds = questions.getColumn(1).values;
|
||||
expect(questionIds).toContain('pack_split');
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { downloadFile } from '../downloadFile.js';
|
||||
import { adminApi } from './adminApi.js';
|
||||
import Modal from './Modal.jsx';
|
||||
import StatusBadge from './StatusBadge.jsx';
|
||||
|
|
@ -29,6 +30,8 @@ export default function ApplicationDetailPage() {
|
|||
const [rejecting, setRejecting] = useState(false);
|
||||
const [reason, setReason] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [consentBusy, setConsentBusy] = useState(false);
|
||||
const [consentError, setConsentError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
adminApi(`/api/admin/applications/${id}`)
|
||||
|
|
@ -36,6 +39,22 @@ export default function ApplicationDetailPage() {
|
|||
.catch((loadError) => setError(loadError.message));
|
||||
}, [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() {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
|
|
@ -125,6 +144,32 @@ export default function ApplicationDetailPage() {
|
|||
) : (
|
||||
<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 ? (
|
||||
<div className="mt-4 flex gap-2">
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -1104,6 +1104,16 @@ a:focus-visible {
|
|||
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 {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { Link, useOutletContext } from 'react-router-dom';
|
||||
import { api } from '../api.js';
|
||||
import { downloadFile } from '../downloadFile.js';
|
||||
import { pathForNextStep, userApi } from '../userApi.js';
|
||||
|
||||
/**
|
||||
|
|
@ -208,6 +209,8 @@ export default function UserDashboardPage() {
|
|||
const me = useOutletContext();
|
||||
const [surveyError, setSurveyError] = useState('');
|
||||
const [surveyBusy, setSurveyBusy] = useState(false);
|
||||
const [consentBusy, setConsentBusy] = useState(false);
|
||||
const [consentError, setConsentError] = useState('');
|
||||
const [previewUrl, setPreviewUrl] = useState('');
|
||||
const [finalPdfUrl, setFinalPdfUrl] = useState('');
|
||||
const [chequeUrl, setChequeUrl] = useState('');
|
||||
|
|
@ -252,6 +255,22 @@ export default function UserDashboardPage() {
|
|||
};
|
||||
}, [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() {
|
||||
setSurveyBusy(true);
|
||||
setSurveyError('');
|
||||
|
|
@ -280,6 +299,21 @@ export default function UserDashboardPage() {
|
|||
<div className={`user-step ${me.steps.consent.done ? 'done' : 'pending'}`}>
|
||||
<strong>1. Consent (PIS / ICF)</strong>
|
||||
<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 className={`user-step ${me.steps.agreement.done ? 'done' : 'pending'}`}>
|
||||
<strong>2. Physician agreement</strong>
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue