296 lines
8.9 KiB
JavaScript
296 lines
8.9 KiB
JavaScript
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<string, unknown>} 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<string, unknown>} 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<string, unknown>} 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);
|
|
}
|