import { createHash } from 'node:crypto'; import Boom from '@hapi/boom'; import { z } from 'zod'; import { db } from '../db/knex.js'; import { auditFromRequest } from '../middleware/audit.js'; import { agreementHtml } from '../pdf/agreementHtml.js'; import { renderPdf } from '../pdf/render.js'; import { refreshAgreementPdfs } from './agreementPdfRefresh.js'; import { readPngDataUrl } from './png.js'; import { recordAudit } from './audit.js'; import { finalizeSubmittedApplication } from './applicationApproval.js'; import { applicationFromRequest, assertCompleteApplication, presentApplication, } from './applications.js'; import { notifySubmission } from './mail.js'; import { getSignatory } from './settings.js'; import { deleteObject, getObject, getPresignedUrl, putObject } from './storage.js'; const PNG_LIMIT = 200 * 1024; const signSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal('DRAWN'), image: z.string().min(1), }), z.object({ type: z.literal('TYPED'), typedName: z.string().trim().min(1).max(255), }), ]); /** * @param {Record} application */ export async function presentWithPdf(application) { await refreshAgreementPdfs(application); const body = presentApplication(application); const finalKey = application.final_pdf_key ? String(application.final_pdf_key) : null; const signedKey = application.signed_pdf_key ? String(application.signed_pdf_key) : null; if (signedKey) { body.signedPdfUrl = await getPresignedUrl(signedKey); } if (finalKey) { body.finalPdfUrl = await getPresignedUrl(finalKey); } // Prefer the final CREST-signed PDF for previews (same as admin). const previewKey = finalKey || signedKey; if (previewKey) { body.pdfUrl = previewKey === finalKey ? body.finalPdfUrl : body.signedPdfUrl; } if (application.cancelled_cheque_key) { body.cancelledChequeUrl = await getPresignedUrl(String(application.cancelled_cheque_key)); } return body; } /** * @param {Record} application */ async function crestSignatory(application) { if (application.status !== 'APPROVED') { return null; } const settings = await getSignatory(); let signatureDataUrl = ''; if (settings.signatureKey) { const bytes = await getObject(settings.signatureKey); signatureDataUrl = `data:image/png;base64,${Buffer.from(bytes).toString('base64')}`; } return { name: settings.name, designation: settings.designation, signatureDataUrl, }; } /** * @param {Record} application * @param {{ draft: boolean, physicianSignature?: { type: string, imageDataUrl?: string, typedName?: string, signedAt?: string } | null }} options */ async function renderAgreement(application, options) { const html = agreementHtml({ application: presentApplication(application), signatory: await crestSignatory(application), physicianSignature: options.physicianSignature, draft: options.draft, }); return renderPdf(html); } /** * The database runs in UTC, but the driver writes JS Dates in the writing * machine's time zone; an explicit UTC string keeps signing times comparable. * @param {Date} date */ function utcDateTime(date) { return date.toISOString().slice(0, 19).replace('T', ' '); } /** * @param {string} image */ function readPng(image) { return readPngDataUrl(image, PNG_LIMIT, 'The signature image must be 200KB or smaller'); } /** * @param {import('@hapi/hapi').Request} request */ export async function previewApplication(request) { const application = await applicationFromRequest(request); if (application.status !== 'DRAFT' && application.status !== 'REJECTED') { throw Boom.conflict('This application can no longer be edited'); } assertCompleteApplication(application); const pdf = await renderAgreement(application, { draft: true, physicianSignature: null }); const key = `applications/${application.public_id}/v${application.version}/draft.pdf`; await putObject(key, pdf, 'application/pdf'); await db('applications').where({ id: application.id }).update({ draft_pdf_key: key }); await auditFromRequest(request, { actorType: 'PHYSICIAN', actorId: application.id, action: 'APPLICATION_PREVIEWED', entityType: 'applications', entityId: application.id, }); return { url: await getPresignedUrl(key) }; } /** * @param {import('@hapi/hapi').Request} request */ export async function signApplication(request) { const application = await applicationFromRequest(request); if (application.status !== 'DRAFT') { throw Boom.conflict('This application can no longer be signed'); } assertCompleteApplication(application); const parsed = signSchema.safeParse(request.payload); if (!parsed.success) { throw Boom.badRequest('A signature is required'); } const signature = parsed.data; const png = signature.type === 'DRAWN' ? readPng(signature.image) : null; const signedAt = new Date(); const signedAtUtc = utcDateTime(signedAt); const physicianSignature = signature.type === 'DRAWN' ? { type: 'DRAWN', imageDataUrl: `data:image/png;base64,${png.toString('base64')}`, signedAt: signedAtUtc, } : { type: 'TYPED', typedName: signature.typedName, signedAt: signedAtUtc }; const pdf = await renderAgreement(application, { draft: false, physicianSignature }); const sha256 = createHash('sha256').update(pdf).digest('hex'); const pdfKey = `applications/${application.public_id}/v${application.version}/signed.pdf`; const imageKey = `applications/${application.public_id}/v${application.version}/signature.png`; let storedImage = false; let storedPdf = false; try { if (png) { await putObject(imageKey, png, 'image/png'); storedImage = true; } await putObject(pdfKey, pdf, 'application/pdf'); storedPdf = true; await db.transaction(async (trx) => { const current = await trx('applications').where({ id: application.id }).forUpdate().first(); if (!current || current.status !== 'DRAFT') { throw Boom.conflict('This application can no longer be signed'); } await trx('signatures').insert({ application_id: application.id, version: application.version, type: signature.type, image_key: png ? imageKey : null, typed_text: signature.type === 'TYPED' ? signature.typedName : null, signed_at: signedAtUtc, created_at: signedAtUtc, ip: request.info.remoteAddress, user_agent: request.headers['user-agent'] ?? null, }); await trx('applications').where({ id: application.id }).update({ status: 'SUBMITTED', submitted_at: signedAt, signed_pdf_key: pdfKey, signed_pdf_sha256: sha256, }); await recordAudit( { actorType: 'PHYSICIAN', actorId: application.id, action: 'APPLICATION_SIGNED', entityType: 'applications', entityId: application.id, ip: request.info.remoteAddress, metadata: { sha256 }, }, trx, ); }); } catch (error) { if (storedPdf) { await deleteObject(pdfKey).catch(() => {}); } if (storedImage) { await deleteObject(imageKey).catch(() => {}); } throw error; } const saved = await db('applications').where({ id: application.id }).first(); try { const finalized = await finalizeSubmittedApplication(request, saved, { decidedBy: null, actorType: 'SYSTEM', actorId: null, action: 'APPLICATION_AUTO_APPROVED', }); const body = await presentWithPdf(finalized.application); body.surveyToken = finalized.surveyToken; return body; } catch (error) { request.log(['warn', 'auto-approve'], error); await notifySubmission(saved); return presentWithPdf(saved); } } /** * @param {import('@hapi/hapi').Request} request */ export async function editApplication(request) { const application = await applicationFromRequest(request); if (application.status === 'SUBMITTED' || application.status === 'APPROVED') { throw Boom.conflict('This application can no longer be edited'); } if (application.status === 'DRAFT') { return presentWithPdf(application); } await db.transaction(async (trx) => { await trx('signatures') .where({ application_id: application.id }) .whereNull('voided_at') .update({ voided_at: new Date() }); await trx('applications') .where({ id: application.id }) .update({ status: 'DRAFT', version: Number(application.version) + 1, }); await recordAudit( { actorType: 'PHYSICIAN', actorId: application.id, action: 'APPLICATION_EDITED', entityType: 'applications', entityId: application.id, ip: request.info.remoteAddress, }, trx, ); }); const saved = await db('applications').where({ id: application.id }).first(); return presentWithPdf(saved); }