- 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 { 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',
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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) => ({
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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. */
|
/** 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({
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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({
|
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');
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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