import type { I18n } from '@lingui/core'; import { msg } from '@lingui/core/macro'; import type { DocumentMeta, Envelope, RecipientRole } from '@prisma/client'; import Konva from 'konva'; import 'konva/skia-backend'; import fs from 'node:fs'; import path from 'node:path'; import type { DateTimeFormatOptions } from 'luxon'; import { DateTime } from 'luxon'; import type { Canvas } from 'skia-canvas'; import { Image as SkiaImage } from 'skia-canvas'; import { match, P } from 'ts-pattern'; import { UAParser } from 'ua-parser-js'; import { DOCUMENT_STATUS } from '../../constants/document'; import { APP_I18N_OPTIONS } from '../../constants/i18n'; import { RECIPIENT_ROLES_DESCRIPTION } from '../../constants/recipient-roles'; import type { TDocumentAuditLog } from '../../types/document-audit-logs'; import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs'; import { formatDocumentAuditLogAction } from '../../utils/document-audit-logs'; import { ensureFontLibrary } from './helpers'; export type AuditLogRecipient = { id: number; name: string; email: string; role: RecipientRole; }; type GenerateAuditLogsOptions = { envelope: Omit & { documentMeta: DocumentMeta; }; envelopeItems: string[]; recipients: AuditLogRecipient[]; auditLogs: TDocumentAuditLog[]; hidePoweredBy: boolean; pageWidth: number; pageHeight: number; i18n: I18n; envelopeOwner: { email: string; name: string; }; }; const parser = new UAParser(); const textMutedForegroundLight = '#929DAE'; const textForeground = '#000'; const textMutedForeground = '#64748B'; const textBase = 10; const textSm = 9; const textXs = 8; const fontMedium = '500'; const pageTopMargin = 60; const pageBottomMargin = 27; const contentMaxWidth = 768; const rowPadding = 10; const titleFontSize = 18; type RenderOverviewCardLabelAndTextOptions = { label: string; text: string | string[]; width: number; groupX?: number; }; const renderOverviewCardLabels = (options: RenderOverviewCardLabelAndTextOptions) => { const { width, text } = options; const labelYSpacing = 4; const group = new Konva.Group({ x: options.groupX ?? 0, }); const label = new Konva.Text({ x: 0, y: 0, text: options.label, fontStyle: fontMedium, fontFamily: 'Inter', fill: textForeground, fontSize: textSm, }); group.add(label); if (typeof text === 'string') { const value = new Konva.Text({ x: 0, y: label.height() + labelYSpacing, width: width - label.width(), fontFamily: 'Inter', text, fill: textForeground, wrap: 'char', fontSize: textSm, }); group.add(value); } else { for (const textValue of text) { const value = new Konva.Text({ x: 0, y: group.getClientRect().height + 4, width: width - label.width(), fontFamily: 'Inter', text: '• ' + textValue, fill: textForeground, wrap: 'char', fontSize: textSm, }); group.add(value); } } return group; }; type RenderVerticalLabelAndTextOptions = { label: string; text: string; width?: number; align?: 'left' | 'right'; x?: number; y?: number; textFontFamily?: string; }; const renderVerticalLabelAndText = (options: RenderVerticalLabelAndTextOptions) => { const { label, text, width, align, x, y, textFontFamily } = options; const group = new Konva.Group({ x: x ?? 0, y: y ?? 0, }); const konvaLabel = new Konva.Text({ align: align ?? 'left', fontFamily: 'Inter', width, text: label, fontSize: textXs, fill: textMutedForegroundLight, }); group.add(konvaLabel); const konvaText = new Konva.Text({ y: group.getClientRect().height + 6, align: align ?? 'left', fontFamily: textFontFamily ?? 'Inter', width, text: text, fontSize: textXs, fill: textForeground, }); group.add(konvaText); return group; }; type RenderOverviewCardOptions = { envelope: Omit & { documentMeta: DocumentMeta; }; envelopeItems: string[]; envelopeOwner: { email: string; name: string; }; recipients: AuditLogRecipient[]; width: number; i18n: I18n; }; const renderOverviewCard = (options: RenderOverviewCardOptions) => { const { envelope, envelopeItems, envelopeOwner, recipients, width, i18n } = options; const cardPadding = 16; const overviewCard = new Konva.Group(); const columnSpacing = 10; const columnWidth = (width - columnSpacing) / 2; const rowVerticalSpacing = 32; const rowOne = new Konva.Group({ x: cardPadding, y: cardPadding, }); const envelopeIdLabel = renderOverviewCardLabels({ label: i18n._(msg`Envelope ID`), text: envelope.id, width: columnWidth, }); const ownerLabel = renderOverviewCardLabels({ label: i18n._(msg`Owner`), text: `${envelopeOwner.name} (${envelopeOwner.email})`, width: columnWidth, groupX: columnWidth + columnSpacing, }); rowOne.add(envelopeIdLabel); rowOne.add(ownerLabel); overviewCard.add(rowOne); const rowTwo = new Konva.Group({ x: cardPadding, y: overviewCard.getClientRect().height + rowVerticalSpacing, }); const statusLabel = renderOverviewCardLabels({ label: i18n._(msg`Status`), text: i18n._(envelope.deletedAt ? msg`Deleted` : DOCUMENT_STATUS[envelope.status].description).toUpperCase(), width: columnWidth, }); const timeZoneLabel = renderOverviewCardLabels({ label: i18n._(msg`Time Zone`), text: envelope.documentMeta?.timezone || 'N/A', width: columnWidth, groupX: columnWidth + columnSpacing, }); rowTwo.add(statusLabel); rowTwo.add(timeZoneLabel); overviewCard.add(rowTwo); const rowThree = new Konva.Group({ x: cardPadding, y: overviewCard.getClientRect().height + rowVerticalSpacing, }); const createdAtLabel = renderOverviewCardLabels({ label: i18n._(msg`Created At`), text: DateTime.fromJSDate(envelope.createdAt) .setLocale(APP_I18N_OPTIONS.defaultLocale) .toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)'), width: columnWidth, }); const lastUpdatedLabel = renderOverviewCardLabels({ label: i18n._(msg`Last Updated`), text: DateTime.fromJSDate(envelope.updatedAt) .setLocale(APP_I18N_OPTIONS.defaultLocale) .toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)'), width: columnWidth, groupX: columnWidth + columnSpacing, }); rowThree.add(createdAtLabel); rowThree.add(lastUpdatedLabel); overviewCard.add(rowThree); const rowFour = new Konva.Group({ x: cardPadding, y: overviewCard.getClientRect().height + rowVerticalSpacing, }); const enclosedDocumentsLabel = renderOverviewCardLabels({ label: i18n._(msg`Enclosed Documents`), text: envelopeItems, width: columnWidth, }); const recipientsLabel = renderOverviewCardLabels({ label: i18n._(msg`Recipients`), text: recipients.map( (recipient) => `[${i18n._(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}] ${recipient.name} (${recipient.email})`, ), width: columnWidth, groupX: columnWidth + columnSpacing, }); rowFour.add(enclosedDocumentsLabel); rowFour.add(recipientsLabel); overviewCard.add(rowFour); // Create rect border around the overview card const cardRect = new Konva.Rect({ x: 0, y: 0, width, height: overviewCard.getClientRect().height + cardPadding * 2, stroke: '#e5e7eb', strokeWidth: 1.5, cornerRadius: 8, }); overviewCard.add(cardRect); return overviewCard; }; type RenderRowOptions = { auditLog: TDocumentAuditLog; width: number; i18n: I18n; }; const renderRow = (options: RenderRowOptions) => { const { auditLog, width, i18n } = options; const paddingWithinCard = 12; const columnSpacing = 10; const columnWidth = (width - paddingWithinCard * 2 - columnSpacing) / 2; const indicatorWidth = 3; const indicatorPaddingRight = 10; const rowGroup = new Konva.Group(); const rowHeaderGroup = new Konva.Group(); const auditLogIndicatorColor = new Konva.Circle({ x: indicatorWidth, y: indicatorWidth + 3, radius: indicatorWidth, fill: getAuditLogIndicatorColor(auditLog.type), }); const auditLogTypeText = new Konva.Text({ x: indicatorWidth + indicatorPaddingRight, y: 0, width: columnWidth - indicatorWidth - indicatorPaddingRight, text: auditLog.type.replace(/_/g, ' '), fontFamily: 'Inter', fontSize: textSm, fontStyle: fontMedium, fill: textMutedForeground, }); const auditLogDescriptionText = new Konva.Text({ x: indicatorWidth + indicatorPaddingRight, y: auditLogTypeText.height() + 4, width: columnWidth - indicatorWidth - indicatorPaddingRight, text: formatDocumentAuditLogAction(i18n, auditLog).description, fontFamily: 'Inter', fontSize: textSm, fill: textForeground, }); const auditLogTimestampText = new Konva.Text({ x: columnWidth + columnSpacing, width: columnWidth, text: DateTime.fromJSDate(auditLog.createdAt).setLocale(APP_I18N_OPTIONS.defaultLocale).toLocaleString(dateFormat), fontFamily: 'Inter', align: 'right', fontSize: textSm, fill: textMutedForeground, }); rowHeaderGroup.add(auditLogIndicatorColor); rowHeaderGroup.add(auditLogTypeText); rowHeaderGroup.add(auditLogDescriptionText); rowHeaderGroup.add(auditLogTimestampText); rowHeaderGroup.setAttrs({ x: paddingWithinCard, y: paddingWithinCard, } satisfies Partial); rowGroup.add(rowHeaderGroup); // Draw border line. const borderLine = new Konva.Line({ points: [0, 0, width - paddingWithinCard * 2, 0], stroke: '#e5e7eb', strokeWidth: 1, x: paddingWithinCard, y: rowGroup.getClientRect().height + paddingWithinCard + 12, }); rowGroup.add(borderLine); const bottomSection = new Konva.Group({ x: paddingWithinCard, y: rowGroup.getClientRect().height + paddingWithinCard + 12, }); // Row 1 Column 1 const userLabel = renderVerticalLabelAndText({ label: i18n._(msg`User`).toUpperCase(), text: auditLog.email || 'N/A', align: 'left', width: columnWidth, textFontFamily: 'ui-monospace', }); // Row 1 Column 2 const ipAddressLabel = renderVerticalLabelAndText({ label: i18n._(msg`IP Address`).toUpperCase(), text: auditLog.ipAddress || 'N/A', align: 'right', x: columnWidth + columnSpacing, width: columnWidth, textFontFamily: 'ui-monospace', }); bottomSection.add(userLabel); bottomSection.add(ipAddressLabel); parser.setUA(auditLog.userAgent || ''); const userAgentInfo = parser.getResult(); // Row 2 Column 1 const userAgentLabel = renderVerticalLabelAndText({ label: i18n._(msg`User Agent`).toUpperCase(), text: i18n._(formatUserAgent(auditLog.userAgent, userAgentInfo)), align: 'left', width, y: bottomSection.getClientRect().height + 16, }); bottomSection.add(userAgentLabel); rowGroup.add(bottomSection); const cardRect = new Konva.Rect({ x: 0, y: 0, width: rowGroup.getClientRect().width, height: rowGroup.getClientRect().height + paddingWithinCard * 2, stroke: '#e5e7eb', strokeWidth: 1, cornerRadius: 8, }); rowGroup.add(cardRect); return rowGroup; }; const renderBranding = () => { const branding = new Konva.Group(); const brandingHeight = 16; const logoPath = path.join(process.cwd(), 'public/static/logo.png'); const logo = fs.readFileSync(logoPath); // eslint-disable-next-line @typescript-eslint/consistent-type-assertions const img = new SkiaImage(logo) as unknown as HTMLImageElement; const brandingImage = new Konva.Image({ image: img, height: brandingHeight, width: brandingHeight * (img.width / img.height), }); branding.add(brandingImage); return branding; }; type GroupRowsIntoPagesOptions = { auditLogs: TDocumentAuditLog[]; maxHeight: number; contentWidth: number; i18n: I18n; overviewCard: Konva.Group; }; const groupRowsIntoPages = (options: GroupRowsIntoPagesOptions) => { const { auditLogs, maxHeight, contentWidth, i18n, overviewCard } = options; const groupedRows: Konva.Group[][] = [[]]; const overviewCardHeight = overviewCard.getClientRect().height; // First page has title + overview card let availableHeight = maxHeight - pageTopMargin - overviewCardHeight; let currentGroupedRowIndex = 0; // Group rows into pages. for (const auditLog of auditLogs) { const row = renderRow({ auditLog, width: contentWidth, i18n }); const rowHeight = row.getClientRect().height; const requiredHeight = rowHeight + rowPadding; if (requiredHeight > availableHeight) { currentGroupedRowIndex++; groupedRows[currentGroupedRowIndex] = [row]; // Subsequent pages only have title (no overview card) availableHeight = maxHeight - pageTopMargin; } else { groupedRows[currentGroupedRowIndex].push(row); } // Reduce available height by the row height. availableHeight -= requiredHeight; } return groupedRows; }; type RenderPagesOptions = { groupedRows: Konva.Group[][]; margin: number; pageTopMargin: number; i18n: I18n; overviewCard: Konva.Group; }; const renderPages = (options: RenderPagesOptions) => { const { groupedRows, margin, pageTopMargin, i18n, overviewCard } = options; const rowPadding = 10; const pages: Konva.Group[] = []; // Render the rows for each page. for (const [pageIndex, rows] of groupedRows.entries()) { const pageGroup = new Konva.Group(); // Add title to each page const pageTitle = new Konva.Text({ x: margin, y: 0, height: pageTopMargin, verticalAlign: 'middle', text: i18n._(msg`Audit Log`), fill: textForeground, fontFamily: 'Inter', fontSize: titleFontSize, fontStyle: '700', }); pageGroup.add(pageTitle); // Add overview card only on first page if (pageIndex === 0) { overviewCard.setAttrs({ x: margin, y: pageGroup.getClientRect().height, }); pageGroup.add(overviewCard); } // Add rows to the page for (const row of rows) { const yPosition = pageGroup.getClientRect().height + rowPadding; row.setAttrs({ x: margin, y: yPosition, }); pageGroup.add(row); } pages.push(pageGroup); } return pages; }; export async function renderAuditLogs({ envelope, envelopeOwner, envelopeItems, recipients, auditLogs, pageWidth, pageHeight, i18n, hidePoweredBy, }: GenerateAuditLogsOptions) { ensureFontLibrary(); const minimumMargin = 10; const contentWidth = Math.min(pageWidth - minimumMargin * 2, contentMaxWidth); const margin = (pageWidth - contentWidth) / 2; let stage: Konva.Stage | null = new Konva.Stage({ width: pageWidth, height: pageHeight }); const overviewCard = renderOverviewCard({ envelope, envelopeOwner, envelopeItems, recipients, width: contentWidth, i18n, }); const groupedRows = groupRowsIntoPages({ auditLogs, maxHeight: pageHeight - pageBottomMargin, contentWidth, i18n, overviewCard, }); const pageGroups = renderPages({ groupedRows, margin, pageTopMargin, i18n, overviewCard, }); const brandingGroup = renderBranding(); const brandingRect = brandingGroup.getClientRect(); const brandingTopPadding = 24; const pages: Uint8Array[] = []; let isBrandingPlaced = false; // Render each page group to PDF for (const [index, pageGroup] of pageGroups.entries()) { stage.destroyChildren(); const page = new Konva.Layer(); const footerText = new Konva.Text({ x: margin, y: pageHeight - textXs - 10, text: `${i18n._(msg`Envelope ID`)}: ${envelope.id}`, fontFamily: 'Inter', fontSize: textXs, fill: textMutedForegroundLight, }); page.add(footerText); page.add(pageGroup); // Add branding on the last page if there is space. if (index === pageGroups.length - 1 && !hidePoweredBy) { const remainingHeight = pageHeight - pageGroup.getClientRect().height - pageBottomMargin; if (brandingRect.height + brandingTopPadding <= remainingHeight) { brandingGroup.setAttrs({ x: pageWidth - brandingRect.width - margin, y: pageGroup.getClientRect().height + brandingTopPadding, } satisfies Partial); page.add(brandingGroup); isBrandingPlaced = true; } } stage.add(page); // Export the page and save it. const canvas = page.canvas._canvas as unknown as Canvas; // eslint-disable-line @typescript-eslint/consistent-type-assertions const buffer = await canvas.toBuffer('pdf'); pages.push(new Uint8Array(buffer)); } // Need to create an empty page for the branding if it hasn't been placed yet. if (!hidePoweredBy && !isBrandingPlaced) { stage.destroyChildren(); const page = new Konva.Layer(); brandingGroup.setAttrs({ x: pageWidth - brandingRect.width - margin, y: pageTopMargin, } satisfies Partial); const overflowFooterText = new Konva.Text({ x: margin, y: pageHeight - textXs - 10, text: `${i18n._(msg`Envelope ID`)}: ${envelope.id}`, fontFamily: 'Inter', fontSize: textXs, fill: textMutedForegroundLight, }); page.add(overflowFooterText); page.add(brandingGroup); stage.add(page); // eslint-disable-next-line @typescript-eslint/consistent-type-assertions const canvas = page.canvas._canvas as unknown as Canvas; const buffer = await canvas.toBuffer('pdf'); pages.push(new Uint8Array(buffer)); } stage.destroy(); stage = null; return pages; } const dateFormat: DateTimeFormatOptions = { ...DateTime.DATETIME_SHORT, hourCycle: 'h12', }; /** * Get the color indicator for the audit log type */ const getAuditLogIndicatorColor = (type: string) => match(type) .with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED, () => '#22c55e') // bg-green-500 .with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED, () => '#ef4444') // bg-red-500 .with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT, () => '#f97316') // bg-orange-500 .with( P.union(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED, DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_UNINSERTED), () => '#3b82f6', // bg-blue-500 ) .otherwise(() => '#f1f5f9'); // bg-muted const formatUserAgent = (userAgent: string | null | undefined, userAgentInfo: UAParser.IResult) => { if (!userAgent) { return msg`N/A`; } const browser = userAgentInfo.browser.name; const version = userAgentInfo.browser.version; const os = userAgentInfo.os.name; // If we can parse meaningful browser info, format it nicely if (browser && os) { const browserInfo = version ? `${browser} ${version}` : browser; return msg`${browserInfo} on ${os}`; } return msg`${userAgent}`; };