PPPAIMT-Crest/backend/src/services/agreementDocuments.js

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);
}