From 6d1b72024f9e0df476013cf01acbb25c55009e42 Mon Sep 17 00:00:00 2001 From: Amey Bhagwat Date: Wed, 28 Oct 2026 10:51:50 +0530 Subject: [PATCH] - 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. --- .../migrations/20261027170000_consent_pdf.js | 18 ++ backend/src/pdf/consentHtml.js | 190 ++++++++++++++++++ backend/src/routes/admin.js | 21 ++ backend/src/routes/consent.js | 17 ++ backend/src/services/admin.js | 8 + backend/src/services/consent.js | 8 + backend/src/services/consentPdf.js | 92 +++++++++ backend/src/services/surveyExport.js | 9 +- backend/test/consent.test.js | 124 +++++++++++- backend/test/consentHtml.test.js | 48 +++++ backend/test/surveyExport.test.js | 13 ++ frontend/src/admin/ApplicationDetailPage.jsx | 45 +++++ frontend/src/downloadFile.js | 35 ++++ frontend/src/index.css | 10 + frontend/src/pages/UserDashboardPage.jsx | 34 ++++ frontend/src/pages/UserDashboardPage.test.jsx | 86 ++++++++ 16 files changed, 755 insertions(+), 3 deletions(-) create mode 100644 backend/migrations/20261027170000_consent_pdf.js create mode 100644 backend/src/pdf/consentHtml.js create mode 100644 backend/src/services/consentPdf.js create mode 100644 backend/test/consentHtml.test.js create mode 100644 frontend/src/downloadFile.js create mode 100644 frontend/src/pages/UserDashboardPage.test.jsx diff --git a/backend/migrations/20261027170000_consent_pdf.js b/backend/migrations/20261027170000_consent_pdf.js new file mode 100644 index 0000000..a065f64 --- /dev/null +++ b/backend/migrations/20261027170000_consent_pdf.js @@ -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'); + }); +} diff --git a/backend/src/pdf/consentHtml.js b/backend/src/pdf/consentHtml.js new file mode 100644 index 0000000..ec87f7b --- /dev/null +++ b/backend/src/pdf/consentHtml.js @@ -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, '$1'); +} + +/** + * 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) ? '
' : ' '}`; + }) + .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(`${inline(heading)}`); + } + + continue; + } + + if (lines.every((line) => line.trim().startsWith('- '))) { + const items = lines.map((line) => `
  • ${inline(line.trim().slice(2))}
  • `).join(''); + html.push(``); + continue; + } + + if (surveyTitle) { + html.push(`

    Survey Title: ${paragraph(lines)}

    `); + surveyTitle = false; + continue; + } + + html.push(`

    ${paragraph(lines)}

    `); + } + + 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 }} consent + */ +function icfHtml(consent) { + const rows = CONSENT_STATEMENTS.map((statement) => { + const checked = consent.answers?.[statement.id] === true; + const label = checked ? 'Accepted' : 'Not accepted'; + return ` + + ${escapeHtml(statement.label)} +`; + }).join('\n'); + + return `
    +

    ONLINE
    INFORMED CONSENT FORM (ICF)

    +

    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.

    + +${rows} +
    +

    Participant Name: ${escapeHtml(consent.participantName)}

    +

    Date: ${escapeHtml(displayDate(consent.consentDate))}

    +
    `; +} + +/** + * @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 }} 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 ` + + + +${escapeHtml(meta.version)} - Participant Information Sheet and Informed Consent Form + + + +

    ONLINE
    PARTICIPANT INFORMATION SHEET (PIS)

    +${pisBodyHtml()} +${icfHtml(consent)} + +`; +} diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index e5b00d5..3d384f7 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -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', diff --git a/backend/src/routes/consent.js b/backend/src/routes/consent.js index e857952..b7d8a66 100644 --- a/backend/src/routes/consent.js +++ b/backend/src/routes/consent.js @@ -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} */ 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', diff --git a/backend/src/services/admin.js b/backend/src/services/admin.js index 9098f8f..713d142 100644 --- a/backend/src/services/admin.js +++ b/backend/src/services/admin.js @@ -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) => ({ diff --git a/backend/src/services/consent.js b/backend/src/services/consent.js index 93bd4d7..f7f26e8 100644 --- a/backend/src/services/consent.js +++ b/backend/src/services/consent.js @@ -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); } diff --git a/backend/src/services/consentPdf.js b/backend/src/services/consentPdf.js new file mode 100644 index 0000000..66e524e --- /dev/null +++ b/backend/src/services/consentPdf.js @@ -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} 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} consent + * @param {Record} application + * @returns {Promise} + */ +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 | 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'); +} diff --git a/backend/src/services/surveyExport.js b/backend/src/services/surveyExport.js index 1dcdee0..c0bad64 100644 --- a/backend/src/services/surveyExport.js +++ b/backend/src/services/surveyExport.js @@ -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} 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({ diff --git a/backend/test/consent.test.js b/backend/test/consent.test.js index 08e2710..0bb3b04 100644 --- a/backend/test/consent.test.js +++ b/backend/test/consent.test.js @@ -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); + }); }); diff --git a/backend/test/consentHtml.test.js b/backend/test/consentHtml.test.js new file mode 100644 index 0000000..c40aa5c --- /dev/null +++ b/backend/test/consentHtml.test.js @@ -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 Rao', + consentDate: '2026-10-27', + answers: allAccepted, + }); + + expect(html).toContain('PARTICIPANT INFORMATION SHEET (PIS)'); + expect(html).toContain('INFORMED CONSENT FORM (ICF)'); + expect(html).toContain('Dr <Asha> Rao'); + expect(html).toContain('27/10/2026'); + 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('

    What will you be expected to do?

    '); + expect(body).toContain('Survey Title:'); + expect(body).not.toContain('Background'); + }); +}); diff --git a/backend/test/surveyExport.test.js b/backend/test/surveyExport.test.js index bd2728f..cd882ab 100644 --- a/backend/test/surveyExport.test.js +++ b/backend/test/surveyExport.test.js @@ -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'); diff --git a/frontend/src/admin/ApplicationDetailPage.jsx b/frontend/src/admin/ApplicationDetailPage.jsx index 4d3145c..779932c 100644 --- a/frontend/src/admin/ApplicationDetailPage.jsx +++ b/frontend/src/admin/ApplicationDetailPage.jsx @@ -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() { ) : (

    No signature

    )} +

    Consent

    + {application.consent ? ( + <> +

    + {application.consent.participantName} · consent date{' '} + {application.consent.consentDate} +
    + Submitted at {application.consent.submittedAt} +

    + + {consentError ? ( +

    + {consentError} +

    + ) : null} + + ) : ( +

    Consent not submitted

    + )} {canDecide ? (
    + ) : null} + {consentError ? ( +

    + {consentError} +

    + ) : null}
    2. Physician agreement diff --git a/frontend/src/pages/UserDashboardPage.test.jsx b/frontend/src/pages/UserDashboardPage.test.jsx new file mode 100644 index 0000000..0b9b581 --- /dev/null +++ b/frontend/src/pages/UserDashboardPage.test.jsx @@ -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} me + */ +function renderDashboard(me) { + render( + + + }> + } /> + + + , + ); +} + +describe('dashboard consent download', () => { + /** @type {ReturnType} */ + 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' }); + }); +});