Compare commits

..

1 Commits

Author SHA1 Message Date
Timur Ercan
7c88463f45 chore: remove cummulative mau since meaningless 2025-11-26 15:33:26 +01:00
31 changed files with 151 additions and 1942 deletions

1
.npmrc
View File

@@ -1,2 +1,3 @@
auto-install-peers = true
legacy-peer-deps = true
prefer-dedupe = true

View File

@@ -135,7 +135,7 @@ export const DocumentSigningForm = ({
<div className="flex flex-col gap-4 md:flex-row">
<Button
type="button"
className="w-full bg-black/5 hover:bg-black/10 dark:bg-muted dark:hover:bg-muted/80"
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
variant="secondary"
size="lg"
disabled={typeof window !== 'undefined' && window.history.length <= 1}
@@ -166,7 +166,7 @@ export const DocumentSigningForm = ({
) : recipient.role === RecipientRole.ASSISTANT ? (
<>
<form onSubmit={assistantForm.handleSubmit(onAssistantFormSubmit)}>
<fieldset className="rounded-2xl border border-border bg-white p-3 dark:bg-background">
<fieldset className="dark:bg-background border-border rounded-2xl border bg-white p-3">
<Controller
name="selectedSignerId"
control={assistantForm.control}
@@ -185,7 +185,7 @@ export const DocumentSigningForm = ({
.map((r) => (
<div
key={`${assistantSignersId}-${r.id}`}
className="relative flex flex-col gap-4 rounded-lg border border-border bg-widget p-4"
className="bg-widget border-border relative flex flex-col gap-4 rounded-lg border p-4"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
@@ -203,15 +203,15 @@ export const DocumentSigningForm = ({
{r.name}
{r.id === recipient.id && (
<span className="ml-2 text-muted-foreground">
<span className="text-muted-foreground ml-2">
{_(msg`(You)`)}
</span>
)}
</Label>
<p className="text-xs text-muted-foreground">{r.email}</p>
<p className="text-muted-foreground text-xs">{r.email}</p>
</div>
</div>
<div className="text-xs leading-[inherit] text-muted-foreground">
<div className="text-muted-foreground text-xs leading-[inherit]">
{r.fields.length} {r.fields.length === 1 ? 'field' : 'fields'}
</div>
</div>
@@ -265,7 +265,7 @@ export const DocumentSigningForm = ({
<Input
type="text"
id="full-name"
className="mt-2 bg-background"
className="bg-background mt-2"
value={fullName}
onChange={(e) => setFullName(e.target.value.trimStart())}
/>
@@ -294,7 +294,7 @@ export const DocumentSigningForm = ({
<div className="mt-6 flex flex-col gap-4 md:flex-row">
<Button
type="button"
className="w-full bg-black/5 hover:bg-black/10 dark:bg-muted dark:hover:bg-muted/80"
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
variant="secondary"
size="lg"
disabled={typeof window !== 'undefined' && window.history.length <= 1}

View File

@@ -22,7 +22,7 @@ export const DocumentPageViewRecentActivity = ({
documentId,
userId,
}: DocumentPageViewRecentActivityProps) => {
const { _, i18n } = useLingui();
const { _ } = useLingui();
const {
data,
@@ -48,9 +48,9 @@ export const DocumentPageViewRecentActivity = ({
const documentAuditLogs = useMemo(() => (data?.pages ?? []).flatMap((page) => page.data), [data]);
return (
<section className="flex flex-col rounded-xl border border-border bg-widget dark:bg-background">
<section className="dark:bg-background border-border bg-widget flex flex-col rounded-xl border">
<div className="flex flex-row items-center justify-between border-b px-4 py-3">
<h1 className="font-medium text-foreground">
<h1 className="text-foreground font-medium">
<Trans>Recent activity</Trans>
</h1>
@@ -59,18 +59,18 @@ export const DocumentPageViewRecentActivity = ({
{isLoading && (
<div className="flex h-full items-center justify-center py-16">
<Loader className="h-6 w-6 animate-spin text-muted-foreground" />
<Loader className="text-muted-foreground h-6 w-6 animate-spin" />
</div>
)}
{isLoadingError && (
<div className="flex h-full flex-col items-center justify-center py-16">
<p className="text-sm text-foreground/80">
<p className="text-foreground/80 text-sm">
<Trans>Unable to load document history</Trans>
</p>
<button
onClick={async () => refetch()}
className="mt-2 text-sm text-foreground/70 hover:text-muted-foreground"
className="text-foreground/70 hover:text-muted-foreground mt-2 text-sm"
>
<Trans>Click here to retry</Trans>
</button>
@@ -83,16 +83,16 @@ export const DocumentPageViewRecentActivity = ({
{hasNextPage && (
<li className="relative flex gap-x-4">
<div className="absolute -bottom-6 left-0 top-0 flex w-6 justify-center">
<div className="w-px bg-border" />
<div className="bg-border w-px" />
</div>
<div className="relative flex h-6 w-6 flex-none items-center justify-center bg-widget">
<div className="h-1.5 w-1.5 rounded-full bg-widget ring-1 ring-gray-300 dark:ring-neutral-600" />
<div className="bg-widget relative flex h-6 w-6 flex-none items-center justify-center">
<div className="bg-widget h-1.5 w-1.5 rounded-full ring-1 ring-gray-300 dark:ring-neutral-600" />
</div>
<button
onClick={async () => fetchNextPage()}
className="text-xs text-foreground/70 hover:text-muted-foreground"
className="text-foreground/70 hover:text-muted-foreground text-xs"
>
{isFetchingNextPage ? _(msg`Loading...`) : _(msg`Load older activity`)}
</button>
@@ -101,7 +101,7 @@ export const DocumentPageViewRecentActivity = ({
{documentAuditLogs.length === 0 && (
<div className="flex items-center justify-center py-4">
<p className="text-sm text-muted-foreground/70">
<p className="text-muted-foreground/70 text-sm">
<Trans>No recent activity</Trans>
</p>
</div>
@@ -115,44 +115,44 @@ export const DocumentPageViewRecentActivity = ({
'absolute left-0 top-0 flex w-6 justify-center',
)}
>
<div className="w-px bg-border" />
<div className="bg-border w-px" />
</div>
<div className="relative flex h-6 w-6 flex-none items-center justify-center bg-widget text-foreground/40">
<div className="bg-widget text-foreground/40 relative flex h-6 w-6 flex-none items-center justify-center">
{match(auditLog.type)
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED, () => (
<div className="rounded-full border border-gray-300 bg-widget p-1 dark:border-neutral-600">
<div className="bg-widget rounded-full border border-gray-300 p-1 dark:border-neutral-600">
<CheckCheckIcon className="h-3 w-3" aria-hidden="true" />
</div>
))
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED, () => (
<div className="rounded-full border border-gray-300 bg-widget p-1 dark:border-neutral-600">
<div className="bg-widget rounded-full border border-gray-300 p-1 dark:border-neutral-600">
<CheckIcon className="h-3 w-3" aria-hidden="true" />
</div>
))
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED, () => (
<div className="rounded-full border border-gray-300 bg-widget p-1 dark:border-neutral-600">
<div className="bg-widget rounded-full border border-gray-300 p-1 dark:border-neutral-600">
<AlertTriangle className="h-3 w-3" aria-hidden="true" />
</div>
))
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED, () => (
<div className="rounded-full border border-gray-300 bg-widget p-1 dark:border-neutral-600">
<div className="bg-widget rounded-full border border-gray-300 p-1 dark:border-neutral-600">
<MailOpen className="h-3 w-3" aria-hidden="true" />
</div>
))
.otherwise(() => (
<div className="h-1.5 w-1.5 rounded-full bg-widget ring-1 ring-gray-300 dark:ring-neutral-600" />
<div className="bg-widget h-1.5 w-1.5 rounded-full ring-1 ring-gray-300 dark:ring-neutral-600" />
))}
</div>
<p
className="flex-auto truncate py-0.5 text-xs leading-5 text-muted-foreground dark:text-muted-foreground/70"
title={formatDocumentAuditLogAction(i18n, auditLog, userId).description}
className="text-muted-foreground dark:text-muted-foreground/70 flex-auto truncate py-0.5 text-xs leading-5"
title={formatDocumentAuditLogAction(_, auditLog, userId).description}
>
{formatDocumentAuditLogAction(i18n, auditLog, userId).description}
{formatDocumentAuditLogAction(_, auditLog, userId).description}
</p>
<time className="flex-none py-0.5 text-xs leading-5 text-muted-foreground dark:text-muted-foreground/70">
<time className="text-muted-foreground dark:text-muted-foreground/70 flex-none py-0.5 text-xs leading-5">
{DateTime.fromJSDate(auditLog.createdAt).toRelative({ style: 'short' })}
</time>
</li>

View File

@@ -57,24 +57,17 @@ export const EnvelopeSignerCompleteDialog = () => {
return;
}
const isEnvelopeItemSwitch = nextField.envelopeItemId !== currentEnvelopeItem?.id;
if (isEnvelopeItemSwitch) {
if (nextField.envelopeItemId !== currentEnvelopeItem?.id) {
setCurrentEnvelopeItem(nextField.envelopeItemId);
}
const fieldTooltip = document.querySelector(`#field-tooltip`);
if (fieldTooltip) {
fieldTooltip.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
setShowPendingFieldTooltip(true);
setTimeout(
() => {
const fieldTooltip = document.querySelector(`#field-tooltip`);
if (fieldTooltip) {
fieldTooltip.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
},
isEnvelopeItemSwitch ? 150 : 50,
);
};
const handleOnCompleteClick = async (

View File

@@ -93,9 +93,7 @@ export const DocumentLogsTable = ({ documentId }: DocumentLogsTableProps) => {
{
header: _(msg`Action`),
accessorKey: 'type',
cell: ({ row }) => (
<span>{formatDocumentAuditLogAction(i18n, row.original).description}</span>
),
cell: ({ row }) => <span>{formatDocumentAuditLogAction(_, row.original).description}</span>,
},
{
header: 'IP Address',

View File

@@ -65,7 +65,7 @@ const formatUserAgent = (userAgent: string | null | undefined, userAgentInfo: UA
};
export const InternalAuditLogTable = ({ logs }: AuditLogDataTableProps) => {
const { _, i18n } = useLingui();
const { _ } = useLingui();
const parser = new UAParser();
@@ -73,7 +73,7 @@ export const InternalAuditLogTable = ({ logs }: AuditLogDataTableProps) => {
<div className="space-y-4">
{logs.map((log, index) => {
parser.setUA(log.userAgent || '');
const formattedAction = formatDocumentAuditLogAction(i18n, log);
const formattedAction = formatDocumentAuditLogAction(_, log);
const userAgentInfo = parser.getResult();
return (
@@ -95,17 +95,17 @@ export const InternalAuditLogTable = ({ logs }: AuditLogDataTableProps) => {
/>
<div>
<div className="text-sm font-medium uppercase tracking-wide text-muted-foreground print:text-[8pt]">
<div className="text-muted-foreground text-sm font-medium uppercase tracking-wide print:text-[8pt]">
{log.type.replace(/_/g, ' ')}
</div>
<div className="text-sm font-medium text-foreground print:text-[8pt]">
<div className="text-foreground text-sm font-medium print:text-[8pt]">
{formattedAction.description}
</div>
</div>
</div>
<div className="text-sm text-muted-foreground print:text-[8pt]">
<div className="text-muted-foreground text-sm print:text-[8pt]">
{DateTime.fromJSDate(log.createdAt)
.setLocale(APP_I18N_OPTIONS.defaultLocale)
.toLocaleString(dateFormat)}
@@ -117,27 +117,27 @@ export const InternalAuditLogTable = ({ logs }: AuditLogDataTableProps) => {
{/* Details Section - Two column layout */}
<div className="grid grid-cols-2 gap-x-8 gap-y-2 text-xs print:text-[6pt]">
<div>
<div className="font-medium uppercase tracking-wide text-muted-foreground/70">
<div className="text-muted-foreground/70 font-medium uppercase tracking-wide">
{_(msg`User`)}
</div>
<div className="mt-1 font-mono text-foreground">{log.email || 'N/A'}</div>
<div className="text-foreground mt-1 font-mono">{log.email || 'N/A'}</div>
</div>
<div className="text-right">
<div className="font-medium uppercase tracking-wide text-muted-foreground/70">
<div className="text-muted-foreground/70 font-medium uppercase tracking-wide">
{_(msg`IP Address`)}
</div>
<div className="mt-1 font-mono text-foreground">{log.ipAddress || 'N/A'}</div>
<div className="text-foreground mt-1 font-mono">{log.ipAddress || 'N/A'}</div>
</div>
<div className="col-span-2">
<div className="font-medium uppercase tracking-wide text-muted-foreground/70">
<div className="text-muted-foreground/70 font-medium uppercase tracking-wide">
{_(msg`User Agent`)}
</div>
<div className="mt-1 text-foreground">
<div className="text-foreground mt-1">
{_(formatUserAgent(log.userAgent, userAgentInfo))}
</div>
</div>

View File

@@ -152,12 +152,6 @@ export default function AdminStatsPage({ loaderData }: Route.ComponentProps) {
<div className="mt-5 grid grid-cols-2 gap-8">
<MonthlyActiveUsersChart title={_(msg`MAU (signed in)`)} data={monthlyActiveUsers} />
<MonthlyActiveUsersChart
title={_(msg`Cumulative MAU (signed in)`)}
data={monthlyActiveUsers}
cummulative
/>
<AdminStatsUsersWithDocumentsChart
data={monthlyUsersWithDocuments}
title={_(msg`MAU (created document)`)}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

After

Width:  |  Height:  |  Size: 144 KiB

View File

@@ -8,8 +8,3 @@ export const MIN_STANDARD_FONT_SIZE = 8;
export const MIN_HANDWRITING_FONT_SIZE = 20;
export const CAVEAT_FONT_PATH = () => `${NEXT_PUBLIC_WEBAPP_URL()}/fonts/caveat.ttf`;
export const PDF_SIZE_A4_72PPI = {
width: 595,
height: 842,
};

View File

@@ -7,7 +7,7 @@ import {
rotateDegrees,
translate,
} from '@cantoo/pdf-lib';
import type { DocumentData, Envelope, EnvelopeItem, Field } from '@prisma/client';
import type { DocumentData, DocumentMeta, Envelope, EnvelopeItem, Field } from '@prisma/client';
import {
DocumentStatus,
EnvelopeType,
@@ -20,14 +20,13 @@ import path from 'node:path';
import { groupBy } from 'remeda';
import { match } from 'ts-pattern';
import { generateAuditLogPdf } from '@documenso/lib/server-only/pdf/generate-audit-log-pdf';
import { generateCertificatePdf } from '@documenso/lib/server-only/pdf/generate-certificate-pdf';
import { prisma } from '@documenso/prisma';
import { signPdf } from '@documenso/signing';
import { PDF_SIZE_A4_72PPI } from '../../../constants/pdf';
import { AppError, AppErrorCode } from '../../../errors/app-error';
import { sendCompletedEmail } from '../../../server-only/document/send-completed-email';
import { getAuditLogsPdf } from '../../../server-only/htmltopdf/get-audit-logs-pdf';
import { getCertificatePdf } from '../../../server-only/htmltopdf/get-certificate-pdf';
import { addRejectionStampToPdf } from '../../../server-only/pdf/add-rejection-stamp-to-pdf';
import { flattenAnnotations } from '../../../server-only/pdf/flatten-annotations';
import { flattenForm } from '../../../server-only/pdf/flatten-form';
@@ -49,7 +48,7 @@ import { putPdfFileServerSide } from '../../../universal/upload/put-file.server'
import { fieldsContainUnsignedRequiredField } from '../../../utils/advanced-fields-helpers';
import { isDocumentCompleted } from '../../../utils/document';
import { createDocumentAuditLogData } from '../../../utils/document-audit-logs';
import { mapDocumentIdToSecondaryId } from '../../../utils/envelope';
import { mapDocumentIdToSecondaryId, mapSecondaryIdToDocumentId } from '../../../utils/envelope';
import type { JobRunIO } from '../../client/_internal/job';
import type { TSealDocumentJobDefinition } from './seal-document';
@@ -69,19 +68,8 @@ export const run = async ({
secondaryId: mapDocumentIdToSecondaryId(documentId),
},
include: {
user: {
select: {
name: true,
email: true,
},
},
documentMeta: true,
recipients: true,
fields: {
include: {
signature: true,
},
},
envelopeItems: {
include: {
documentData: true,
@@ -114,20 +102,23 @@ export const run = async ({
});
}
let { envelopeItems } = envelope;
const fields = envelope.fields;
let envelopeItems = envelope.envelopeItems;
if (envelopeItems.length < 1) {
throw new Error(`Document ${envelope.id} has no envelope items`);
}
const recipientsWithoutCCers = envelope.recipients.filter(
(recipient) => recipient.role !== RecipientRole.CC,
);
const recipients = await prisma.recipient.findMany({
where: {
envelopeId: envelope.id,
role: {
not: RecipientRole.CC,
},
},
});
// Determine if the document has been rejected by checking if any recipient has rejected it
const rejectedRecipient = recipientsWithoutCCers.find(
const rejectedRecipient = recipients.find(
(recipient) => recipient.signingStatus === SigningStatus.REJECTED,
);
@@ -136,6 +127,15 @@ export const run = async ({
// Get the rejection reason from the rejected recipient
const rejectionReason = rejectedRecipient?.rejectionReason ?? '';
const fields = await prisma.field.findMany({
where: {
envelopeId: envelope.id,
},
include: {
signature: true,
},
});
// Skip the field check if the document is rejected
if (!isRejected && fieldsContainUnsignedRequiredField(fields)) {
throw new Error(`Document ${envelope.id} has unsigned required fields`);
@@ -164,32 +164,13 @@ export const run = async ({
});
}
let certificateDoc: PDFDocument | null = null;
let auditLogDoc: PDFDocument | null = null;
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
if (settings.includeSigningCertificate || settings.includeAuditLog) {
const certificatePayload = {
envelope,
recipients: envelope.recipients, // Need to use the recipients from envelope which contains ALL recipients.
fields,
language: envelope.documentMeta.language,
envelopeOwner: {
email: envelope.user.email,
name: envelope.user.name || '',
},
envelopeItems: envelopeItems.map((item) => item.title),
pageWidth: PDF_SIZE_A4_72PPI.width,
pageHeight: PDF_SIZE_A4_72PPI.height,
};
const [createdCertificatePdf, createdAuditLogPdf] = await Promise.all([
settings.includeSigningCertificate ? generateCertificatePdf(certificatePayload) : null,
settings.includeAuditLog ? generateAuditLogPdf(certificatePayload) : null,
]);
certificateDoc = createdCertificatePdf;
auditLogDoc = createdAuditLogPdf;
}
const { certificateData, auditLogData } = await getCertificateAndAuditLogData({
legacyDocumentId,
documentMeta: envelope.documentMeta,
settings,
});
const newDocumentData: Array<{ oldDocumentDataId: string; newDocumentDataId: string }> = [];
@@ -208,8 +189,8 @@ export const run = async ({
envelopeItemFields,
isRejected,
rejectionReason,
certificateDoc,
auditLogDoc,
certificateData,
auditLogData,
});
newDocumentData.push(result);
@@ -305,8 +286,8 @@ type DecorateAndSignPdfOptions = {
envelopeItemFields: Field[];
isRejected: boolean;
rejectionReason: string;
certificateDoc: PDFDocument | null;
auditLogDoc: PDFDocument | null;
certificateData: Buffer | null;
auditLogData: Buffer | null;
};
/**
@@ -318,8 +299,8 @@ const decorateAndSignPdf = async ({
envelopeItemFields,
isRejected,
rejectionReason,
certificateDoc,
auditLogDoc,
certificateData,
auditLogData,
}: DecorateAndSignPdfOptions) => {
const pdfData = await getFileServerSide(envelopeItem.documentData);
@@ -335,7 +316,9 @@ const decorateAndSignPdf = async ({
await addRejectionStampToPdf(pdfDoc, rejectionReason);
}
if (certificateDoc) {
if (certificateData) {
const certificateDoc = await PDFDocument.load(certificateData);
const certificatePages = await pdfDoc.copyPages(
certificateDoc,
certificateDoc.getPageIndices(),
@@ -346,7 +329,9 @@ const decorateAndSignPdf = async ({
});
}
if (auditLogDoc) {
if (auditLogData) {
const auditLogDoc = await PDFDocument.load(auditLogData);
const auditLogPages = await pdfDoc.copyPages(auditLogDoc, auditLogDoc.getPageIndices());
auditLogPages.forEach((page) => {
@@ -471,3 +456,47 @@ const decorateAndSignPdf = async ({
newDocumentDataId: newDocumentData.id,
};
};
export const getCertificateAndAuditLogData = async ({
legacyDocumentId,
documentMeta,
settings,
}: {
legacyDocumentId: number;
documentMeta: DocumentMeta;
settings: { includeSigningCertificate: boolean; includeAuditLog: boolean };
}) => {
const getCertificateDataPromise = settings.includeSigningCertificate
? getCertificatePdf({
documentId: legacyDocumentId,
language: documentMeta.language,
}).catch((e) => {
console.log('Failed to get certificate PDF');
console.error(e);
return null;
})
: null;
const getAuditLogDataPromise = settings.includeAuditLog
? getAuditLogsPdf({
documentId: legacyDocumentId,
language: documentMeta.language,
}).catch((e) => {
console.log('Failed to get audit logs PDF');
console.error(e);
return null;
})
: null;
const [certificateData, auditLogData] = await Promise.all([
getCertificateDataPromise,
getAuditLogDataPromise,
]);
return {
certificateData,
auditLogData,
};
};

View File

@@ -1,6 +1,3 @@
/**
* @deprecated We use Konva to generate the audit logs PDF now.
*/
import { DateTime } from 'luxon';
import type { Browser } from 'playwright';

View File

@@ -1,6 +1,3 @@
/**
* @deprecated We use Konva to generate the certificate PDF now..
*/
import { DateTime } from 'luxon';
import type { Browser } from 'playwright';

View File

@@ -1,60 +0,0 @@
import { i18n } from '@lingui/core';
import { prisma } from '@documenso/prisma';
import { ZSupportedLanguageCodeSchema } from '../../constants/i18n';
import { parseDocumentAuditLogData } from '../../utils/document-audit-logs';
import { getTranslations } from '../../utils/i18n';
import { getOrganisationClaimByTeamId } from '../organisation/get-organisation-claims';
import type { GenerateCertificatePdfOptions } from './generate-certificate-pdf';
import { mergeFilesIntoPdf } from './generate-certificate-pdf';
import { renderAuditLogs } from './render-audit-logs';
type GenerateAuditLogPdfOptions = GenerateCertificatePdfOptions & {
envelopeItems: string[];
};
export const generateAuditLogPdf = async (options: GenerateAuditLogPdfOptions) => {
const { envelope, envelopeOwner, envelopeItems, recipients, language, pageWidth, pageHeight } =
options;
const documentLanguage = ZSupportedLanguageCodeSchema.parse(language);
const [organisationClaim, auditLogs, messages] = await Promise.all([
getOrganisationClaimByTeamId({ teamId: envelope.teamId }),
getAuditLogs(envelope.id),
getTranslations(documentLanguage),
]);
i18n.loadAndActivate({
locale: documentLanguage,
messages,
});
const auditLogPages = await renderAuditLogs({
envelope,
envelopeOwner,
envelopeItems,
recipients,
auditLogs,
hidePoweredBy: organisationClaim.flags.hidePoweredBy ?? false,
pageWidth,
pageHeight,
i18n,
});
return await mergeFilesIntoPdf(auditLogPages);
};
const getAuditLogs = async (envelopeId: string) => {
const auditLogs = await prisma.documentAuditLog.findMany({
where: {
envelopeId,
},
orderBy: {
createdAt: 'desc',
},
});
return auditLogs.map((auditLog) => parseDocumentAuditLogData(auditLog));
};

View File

@@ -1,154 +0,0 @@
import { PDFDocument } from '@cantoo/pdf-lib';
import { i18n } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import type { DocumentMeta } from '@prisma/client';
import type { Envelope, Field, Recipient, Signature } from '@prisma/client';
import { FieldType } from '@prisma/client';
import { prop, sortBy } from 'remeda';
import { match } from 'ts-pattern';
import { ZSupportedLanguageCodeSchema } from '../../constants/i18n';
import type { TDocumentAuditLogBaseSchema } from '../../types/document-audit-logs';
import { extractDocumentAuthMethods } from '../../utils/document-auth';
import { getTranslations } from '../../utils/i18n';
import { getDocumentCertificateAuditLogs } from '../document/get-document-certificate-audit-logs';
import { getOrganisationClaimByTeamId } from '../organisation/get-organisation-claims';
import { renderCertificate } from './render-certificate';
export type GenerateCertificatePdfOptions = {
envelope: Envelope & {
documentMeta: DocumentMeta;
};
envelopeOwner: {
name: string;
email: string;
};
recipients: Recipient[];
fields: (Pick<Field, 'id' | 'type' | 'secondaryId' | 'recipientId'> & {
signature?: Pick<Signature, 'signatureImageAsBase64' | 'typedSignature'> | null;
})[];
language?: string;
pageWidth: number;
pageHeight: number;
};
export const generateCertificatePdf = async (options: GenerateCertificatePdfOptions) => {
const { envelope, envelopeOwner, recipients, fields, language, pageWidth, pageHeight } = options;
const documentLanguage = ZSupportedLanguageCodeSchema.parse(language);
const [organisationClaim, auditLogs, messages] = await Promise.all([
getOrganisationClaimByTeamId({ teamId: envelope.teamId }),
getDocumentCertificateAuditLogs({
envelopeId: envelope.id,
}),
getTranslations(documentLanguage),
]);
i18n.loadAndActivate({
locale: documentLanguage,
messages,
});
const payload = {
recipients: recipients.map((recipient) => {
const recipientId = recipient.id;
const signatureField = fields.find(
(field) => field.recipientId === recipient.id && field.type === FieldType.SIGNATURE,
);
const emailSent: TDocumentAuditLogBaseSchema | undefined = auditLogs['EMAIL_SENT'].find(
(log) => log.type === 'EMAIL_SENT' && log.data.recipientId === recipientId,
);
const documentOpened: TDocumentAuditLogBaseSchema | undefined = auditLogs[
'DOCUMENT_OPENED'
].find((log) => log.type === 'DOCUMENT_OPENED' && log.data.recipientId === recipientId);
const documentRecipientCompleted: TDocumentAuditLogBaseSchema | undefined = auditLogs[
'DOCUMENT_RECIPIENT_COMPLETED'
].find(
(log) =>
log.type === 'DOCUMENT_RECIPIENT_COMPLETED' && log.data.recipientId === recipientId,
);
const documentRecipientRejected: TDocumentAuditLogBaseSchema | undefined = auditLogs[
'DOCUMENT_RECIPIENT_REJECTED'
].find(
(log) => log.type === 'DOCUMENT_RECIPIENT_REJECTED' && log.data.recipientId === recipientId,
);
const extractedAuthMethods = extractDocumentAuthMethods({
documentAuth: envelope.authOptions,
recipientAuth: recipient.authOptions,
});
const insertedAuditLogsWithFieldAuth = sortBy(
auditLogs.DOCUMENT_FIELD_INSERTED.filter(
(log) => log.data.recipientId === recipient.id && log.data.fieldSecurity,
),
[prop('createdAt'), 'desc'],
);
const actionAuthMethod = insertedAuditLogsWithFieldAuth.at(0)?.data?.fieldSecurity?.type;
let authLevel = match(actionAuthMethod)
.with('ACCOUNT', () => i18n._(msg`Account Re-Authentication`))
.with('TWO_FACTOR_AUTH', () => i18n._(msg`Two-Factor Re-Authentication`))
.with('PASSWORD', () => i18n._(msg`Password Re-Authentication`))
.with('PASSKEY', () => i18n._(msg`Passkey Re-Authentication`))
.with('EXPLICIT_NONE', () => i18n._(msg`Email`))
.with(undefined, () => null)
.exhaustive();
if (!authLevel) {
const accessAuthMethod = extractedAuthMethods.derivedRecipientAccessAuth.at(0);
authLevel = match(accessAuthMethod)
.with('ACCOUNT', () => i18n._(msg`Account Authentication`))
.with('TWO_FACTOR_AUTH', () => i18n._(msg`Two-Factor Authentication`))
.with(undefined, () => i18n._(msg`Email`))
.exhaustive();
}
return {
id: recipient.id,
name: recipient.name,
email: recipient.email,
role: recipient.role,
signingStatus: recipient.signingStatus,
signatureField,
rejectionReason: recipient.rejectionReason,
authLevel,
logs: {
emailed: emailSent ?? null,
opened: documentOpened ?? null,
completed: documentRecipientCompleted ?? null,
rejected: documentRecipientRejected ?? null,
},
};
}),
envelopeOwner,
qrToken: envelope.qrToken,
hidePoweredBy: organisationClaim.flags.hidePoweredBy ?? false,
pageWidth,
pageHeight,
i18n,
};
const certificatePages = await renderCertificate(payload);
return await mergeFilesIntoPdf(certificatePages);
};
export async function mergeFilesIntoPdf(buffers: Uint8Array[]) {
const mergedPdf = await PDFDocument.create();
for (const buffer of buffers) {
const pdf = await PDFDocument.load(buffer);
const pages = await mergedPdf.copyPages(pdf, pdf.getPageIndices());
pages.forEach((p) => mergedPdf.addPage(p));
}
return mergedPdf;
}

View File

@@ -1,718 +0,0 @@
import type { I18n } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import type { DocumentMeta } from '@prisma/client';
import type { Envelope, RecipientRole } from '@prisma/client';
import Konva from 'konva';
import 'konva/skia-backend';
import type { DateTimeFormatOptions } from 'luxon';
import { DateTime } from 'luxon';
import fs from 'node:fs';
import path from 'node:path';
import type { Canvas } from 'skia-canvas';
import { FontLibrary } from 'skia-canvas';
import { Image as SkiaImage } from 'skia-canvas';
import { match } from 'ts-pattern';
import { 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 { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import type { TDocumentAuditLog } from '../../types/document-audit-logs';
import { formatDocumentAuditLogAction } from '../../utils/document-audit-logs';
export type AuditLogRecipient = {
id: number;
name: string;
email: string;
role: RecipientRole;
};
type GenerateAuditLogsOptions = {
envelope: Envelope & {
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 = 15;
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: Envelope & {
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<Konva.GroupConfig>);
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 [index, auditLog] of auditLogs.entries()) {
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 [rowIndex, row] of rows.entries()) {
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) {
const fontPath = path.join(process.cwd(), 'public/fonts');
// eslint-disable-next-line react-hooks/rules-of-hooks
FontLibrary.use({
['Caveat']: [path.join(fontPath, 'caveat.ttf')],
['Inter']: [path.join(fontPath, 'inter-variablefont_opsz,wght.ttf')],
});
const minimumMargin = 10;
const contentWidth = Math.min(pageWidth - minimumMargin * 2, contentMaxWidth);
const margin = (pageWidth - contentWidth) / 2;
const stage = new Konva.Stage({ width: pageWidth, height: pageHeight });
const overviewCard = renderOverviewCard({
envelope,
envelopeOwner,
envelopeItems,
recipients,
width: contentWidth,
i18n,
});
const groupedRows = groupRowsIntoPages({
auditLogs,
maxHeight: pageHeight,
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();
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<Konva.GroupConfig>);
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<Konva.GroupConfig>);
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));
}
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, () => '#ef4444f') // 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}`;
};

View File

@@ -1,811 +0,0 @@
import type { I18n } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import type { Field, Signature } from '@prisma/client';
import { SigningStatus } from '@prisma/client';
import type { RecipientRole } from '@prisma/client';
import Konva from 'konva';
import 'konva/skia-backend';
import { DateTime } from 'luxon';
import fs from 'node:fs';
import path from 'node:path';
import sharp from 'sharp';
import type { Canvas } from 'skia-canvas';
import { FontLibrary } from 'skia-canvas';
import { Image as SkiaImage } from 'skia-canvas';
import { UAParser } from 'ua-parser-js';
import { renderSVG } from 'uqr';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { APP_I18N_OPTIONS } from '../../constants/i18n';
import {
RECIPIENT_ROLES_DESCRIPTION,
RECIPIENT_ROLE_SIGNING_REASONS,
} from '../../constants/recipient-roles';
import type { TDocumentAuditLogBaseSchema } from '../../types/document-audit-logs';
type ColumnWidths = [number, number, number];
type BaseAuditLog = Pick<TDocumentAuditLogBaseSchema, 'createdAt' | 'ipAddress' | 'userAgent'>;
export type CertificateRecipient = {
id: number;
name: string;
email: string;
role: RecipientRole;
rejectionReason: string | null;
signingStatus: SigningStatus;
signatureField?: Pick<Field, 'id' | 'secondaryId' | 'recipientId'> & {
signature?: Pick<Signature, 'signatureImageAsBase64' | 'typedSignature'> | null;
};
authLevel: string;
logs: {
emailed: BaseAuditLog | null;
opened: BaseAuditLog | null;
completed: BaseAuditLog | null;
rejected: BaseAuditLog | null;
};
};
type GenerateCertificateOptions = {
recipients: CertificateRecipient[];
qrToken: string | null;
hidePoweredBy: boolean;
i18n: I18n;
envelopeOwner: {
name: string;
email: string;
};
pageWidth: number;
pageHeight: number;
};
// Helper function to get device info from user agent
const getDevice = (userAgent?: string | null): string => {
if (!userAgent) {
return 'Unknown';
}
const parser = new UAParser(userAgent);
parser.setUA(userAgent);
const result = parser.getResult();
return `${result.os.name} - ${result.browser.name} ${result.browser.version}`;
};
const textMutedForegroundLight = '#929DAE';
const textForeground = '#000';
const textMutedForeground = '#64748B';
const textBase = 10;
const textSm = 9;
const textXs = 8;
const fontMedium = '500';
const columnWidthPercentages = [30, 30, 40];
const rowPadding = 12;
const tableHeaderHeight = 38;
const pageTopMargin = 72;
const pageBottomMargin = 12;
const contentMaxWidth = 768;
const titleFontSize = 18;
type RenderLabelAndTextOptions = {
label: string;
text: string;
width: number;
y?: number;
};
const renderLabelAndText = (options: RenderLabelAndTextOptions) => {
const { width, y } = options;
const group = new Konva.Group({
y,
});
const label = new Konva.Text({
x: 0,
y: 0,
text: `${options.label}: `,
fontStyle: fontMedium,
fontFamily: 'Inter',
fill: textMutedForeground,
fontSize: textSm,
});
group.add(label);
const value = new Konva.Text({
x: label.width(),
y: 0,
width: width - label.width(),
fontFamily: 'Inter',
text: options.text,
fill: textMutedForeground,
wrap: 'char',
fontSize: textSm,
});
group.add(value);
return group;
};
type RenderRowHeaderOptions = {
columnWidths: number[];
i18n: I18n;
};
const renderRowHeader = (options: RenderRowHeaderOptions) => {
const { columnWidths, i18n } = options;
const columnOneWidth = columnWidths[0];
const columnTwoWidth = columnWidths[1];
const columnThreeWidth = columnWidths[2];
const headerRow = new Konva.Group();
const headerFontStyling = {
fontFamily: 'Inter',
fontSize: 11,
fontStyle: fontMedium,
verticalAlign: 'middle',
fill: textMutedForeground,
height: tableHeaderHeight,
};
const header1 = new Konva.Text({
x: rowPadding,
width: columnOneWidth,
text: i18n._(msg`Signer Events`),
...headerFontStyling,
});
headerRow.add(header1);
const header2 = new Konva.Text({
x: columnOneWidth + rowPadding,
width: columnTwoWidth,
text: i18n._(msg`Signature`),
...headerFontStyling,
});
headerRow.add(header2);
const header3 = new Konva.Text({
x: columnOneWidth + columnTwoWidth + rowPadding,
width: columnThreeWidth,
text: i18n._(msg`Details`),
...headerFontStyling,
});
headerRow.add(header3);
return headerRow;
};
const columnPadding = 10;
type RenderColumnOptions = {
recipient: CertificateRecipient;
width: number;
i18n: I18n;
envelopeOwner: {
name: string;
email: string;
};
};
const renderColumnOne = (options: RenderColumnOptions) => {
const { recipient, width, i18n } = options;
const columnGroup = new Konva.Group();
const textSectionPadding = 8;
const textFontStyling = {
x: 0,
fontFamily: 'Inter',
wrap: 'char',
lineHeight: 1.2,
fill: textMutedForeground,
width: width - columnPadding,
};
if (recipient.name) {
const nameText = new Konva.Text({
y: 0,
text: recipient.name,
fontSize: textBase,
...textFontStyling,
fontStyle: fontMedium,
});
columnGroup.add(nameText);
}
const emailText = new Konva.Text({
y: columnGroup.getClientRect().height,
text: recipient.email,
fontSize: textBase,
...textFontStyling,
});
columnGroup.add(emailText);
const roleText = new Konva.Text({
y: columnGroup.getClientRect().height + textSectionPadding,
text: i18n._(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName),
fontSize: textSm,
...textFontStyling,
});
columnGroup.add(roleText);
const authLabel = new Konva.Text({
y: columnGroup.getClientRect().height + textSectionPadding,
text: `${i18n._(msg`Authentication Level`)}:`,
fontSize: textSm,
fontStyle: fontMedium,
...textFontStyling,
});
columnGroup.add(authLabel);
const authValue = new Konva.Text({
y: columnGroup.getClientRect().height,
text: recipient.authLevel,
fontSize: textSm,
...textFontStyling,
});
columnGroup.add(authValue);
return columnGroup;
};
const renderColumnTwo = (options: RenderColumnOptions) => {
const { recipient, width, i18n } = options;
// Column 2: Signature
const column = new Konva.Group();
const columnWidth = width - columnPadding;
if (recipient.signatureField?.secondaryId) {
// Signature container with green border
const signatureContainer = new Konva.Group({ x: 0, y: 0 });
const minSignatureHeight = 40;
const maxSignatureWidth = 100;
// Signature content
if (recipient.signatureField?.signature?.signatureImageAsBase64) {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const img = new SkiaImage(
recipient.signatureField?.signature?.signatureImageAsBase64,
) as unknown as HTMLImageElement;
const signatureImage = new Konva.Image({
image: img,
x: 4,
y: 4,
width: maxSignatureWidth,
height: maxSignatureWidth * (img.height / img.width),
});
signatureContainer.add(signatureImage);
} else if (recipient.signatureField?.signature?.typedSignature) {
const typedSig = new Konva.Text({
x: 2,
text: recipient.signatureField?.signature?.typedSignature,
padding: 4,
fontFamily: 'Caveat',
fontSize: 16,
align: 'center',
verticalAlign: 'middle',
width: maxSignatureWidth,
});
if (typedSig.getClientRect().height < minSignatureHeight) {
typedSig.setAttrs({
height: minSignatureHeight,
});
}
signatureContainer.add(typedSig);
}
column.add(signatureContainer);
const signatureHeight = Math.max(signatureContainer.getClientRect().height, minSignatureHeight);
const signatureBorder = new Konva.Rect({
x: 2,
y: 2,
width: maxSignatureWidth,
height: signatureHeight,
stroke: 'rgba(122, 196, 85, 0.6)',
strokeWidth: 1,
cornerRadius: 8,
});
signatureContainer.add(signatureBorder);
const signatureShadow = new Konva.Rect({
x: 0,
y: 0,
width: maxSignatureWidth + 4,
height: signatureHeight + 4,
stroke: 'rgba(122, 196, 85, 0.1)',
strokeWidth: 4,
cornerRadius: 8,
});
signatureContainer.add(signatureShadow);
// Signature ID
const sigIdLabel = new Konva.Text({
x: 0,
y: signatureHeight + 10,
text: `${i18n._(msg`Signature ID`)}:`,
fill: textMutedForeground,
width: columnWidth,
fontFamily: 'Inter',
fontSize: textSm,
fontStyle: fontMedium,
lineHeight: 1.4,
});
column.add(sigIdLabel);
const sigIdValue = new Konva.Text({
x: 0,
y: column.getClientRect().height,
text: recipient.signatureField.secondaryId.toUpperCase(),
fill: textMutedForeground,
fontFamily: 'monospace',
fontSize: textSm,
width: columnWidth,
wrap: 'char',
});
column.add(sigIdValue);
} else {
const naText = new Konva.Text({
x: 0,
y: 0,
text: 'N/A',
fill: textMutedForeground,
fontFamily: 'Inter',
fontSize: textSm,
});
column.add(naText);
}
const ipLabelAndText = renderLabelAndText({
label: i18n._(msg`IP Address`),
text: recipient.logs.completed?.ipAddress ?? i18n._(msg`Unknown`),
width,
y: column.getClientRect().height + 6,
});
column.add(ipLabelAndText);
const deviceLabelAndText = renderLabelAndText({
label: i18n._(msg`Device`),
text: getDevice(recipient.logs.completed?.userAgent),
width,
y: column.getClientRect().height + 6,
});
column.add(deviceLabelAndText);
return column;
};
const renderColumnThree = (options: RenderColumnOptions) => {
const { recipient, width, i18n, envelopeOwner } = options;
const column = new Konva.Group();
const itemsToRender = [
{
label: i18n._(msg`Sent`),
value: recipient.logs.emailed
? DateTime.fromJSDate(recipient.logs.emailed.createdAt)
.setLocale(APP_I18N_OPTIONS.defaultLocale)
.toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)')
: i18n._(msg`Unknown`),
},
{
label: i18n._(msg`Viewed`),
value: recipient.logs.opened
? DateTime.fromJSDate(recipient.logs.opened.createdAt)
.setLocale(APP_I18N_OPTIONS.defaultLocale)
.toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)')
: i18n._(msg`Unknown`),
},
];
if (recipient.logs.rejected) {
itemsToRender.push({
label: i18n._(msg`Rejected`),
value: DateTime.fromJSDate(recipient.logs.rejected.createdAt)
.setLocale(APP_I18N_OPTIONS.defaultLocale)
.toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)'),
});
} else {
itemsToRender.push({
label: i18n._(msg`Signed`),
value: recipient.logs.completed
? DateTime.fromJSDate(recipient.logs.completed.createdAt)
.setLocale(APP_I18N_OPTIONS.defaultLocale)
.toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)')
: i18n._(msg`Unknown`),
});
}
const isOwner = recipient.email.toLowerCase() === envelopeOwner.email.toLowerCase();
itemsToRender.push({
label: i18n._(msg`Reason`),
value:
recipient.signingStatus === SigningStatus.REJECTED
? recipient.rejectionReason || ''
: isOwner
? i18n._(msg`I am the owner of this document`)
: i18n._(RECIPIENT_ROLE_SIGNING_REASONS[recipient.role]),
});
for (const [index, item] of itemsToRender.entries()) {
const labelAndText = renderLabelAndText({
label: item.label,
text: item.value,
width,
y: column.getClientRect().height + (index === 0 ? 0 : 8),
});
column.add(labelAndText);
}
return column;
};
type RenderRowOptions = {
recipient: CertificateRecipient;
columnWidths: ColumnWidths;
i18n: I18n;
envelopeOwner: {
name: string;
email: string;
};
};
const renderRow = (options: RenderRowOptions) => {
const { recipient, columnWidths, i18n, envelopeOwner } = options;
const rowGroup = new Konva.Group();
const width = columnWidths[0] + columnWidths[1] + columnWidths[2];
// Draw top border line.
const borderLine = new Konva.Line({
points: [0, 0, width + rowPadding * 2, 0],
stroke: '#e5e7eb',
strokeWidth: 1,
});
rowGroup.add(borderLine);
// Column 1: Signer Events
const columnGroup = renderColumnOne({
recipient,
width: columnWidths[0],
i18n,
envelopeOwner,
});
columnGroup.setAttrs({
x: rowPadding,
y: rowPadding,
} satisfies Partial<Konva.GroupConfig>);
rowGroup.add(columnGroup);
const columnTwoGroup = renderColumnTwo({
recipient,
width: columnWidths[1],
i18n,
envelopeOwner,
});
columnTwoGroup.setAttrs({
x: rowPadding + columnWidths[0],
y: rowPadding,
} satisfies Partial<Konva.GroupConfig>);
rowGroup.add(columnTwoGroup);
// Column 3: Details
const columnThreeGroup = renderColumnThree({
recipient,
width: columnWidths[2],
i18n,
envelopeOwner,
});
columnThreeGroup.setAttrs({
x: rowPadding + columnWidths[0] + columnWidths[1],
y: rowPadding,
} satisfies Partial<Konva.GroupConfig>);
rowGroup.add(columnThreeGroup);
const rowBottomPadding = new Konva.Rect({
x: 0,
y: rowGroup.getClientRect().height,
width: rowGroup.getClientRect().width,
height: rowPadding,
});
rowGroup.add(rowBottomPadding);
return rowGroup;
};
const renderBranding = async ({ qrToken, i18n }: { qrToken: string | null; i18n: I18n }) => {
const branding = new Konva.Group();
const brandingHeight = 12;
const text = new Konva.Text({
x: 0,
verticalAlign: 'middle',
text: i18n._(msg`Signing certificate provided by`) + ':',
fontStyle: fontMedium,
fontFamily: 'Inter',
fontSize: textSm,
height: brandingHeight,
});
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 documensoImage = new Konva.Image({
image: img,
height: brandingHeight,
width: brandingHeight * (img.width / img.height),
x: text.width() + 16,
});
const qrSize = qrToken ? 72 : 0;
const logoGroup = new Konva.Group({
y: qrSize + 16,
});
logoGroup.add(text);
logoGroup.add(documensoImage);
branding.add(logoGroup);
if (qrToken) {
const qrSvg = renderSVG(`${NEXT_PUBLIC_WEBAPP_URL()}/share/${qrToken}`, {
ecc: 'Q',
});
const svgImage = await sharp(Buffer.from(qrSvg)).toFormat('png').toBuffer();
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const qrSkiaImage = new SkiaImage(svgImage) as unknown as HTMLImageElement;
const qrImage = new Konva.Image({
image: qrSkiaImage,
height: qrSize,
width: qrSize,
x: branding.getClientRect().width - qrSize,
y: 0,
});
branding.add(qrImage);
}
return branding;
};
type GroupRowsIntoPagesOptions = {
recipients: CertificateRecipient[];
maxHeight: number;
i18n: I18n;
columnWidths: ColumnWidths;
envelopeOwner: {
name: string;
email: string;
};
};
const groupRowsIntoPages = (options: GroupRowsIntoPagesOptions) => {
const { recipients, maxHeight, i18n, columnWidths, envelopeOwner } = options;
const rowHeader = renderRowHeader({ columnWidths, i18n });
const rowHeaderHeight = rowHeader.getClientRect().height;
const groupedRows: Konva.Group[][] = [[]];
let availablePageHeight = maxHeight - rowHeaderHeight;
let currentGroupedRowIndex = 0;
// Group rows into pages.
for (const recipient of recipients) {
const row = renderRow({ recipient, columnWidths, i18n, envelopeOwner });
const rowHeight = row.getClientRect().height;
if (rowHeight > availablePageHeight) {
currentGroupedRowIndex++;
groupedRows[currentGroupedRowIndex] = [row];
availablePageHeight = maxHeight - rowHeaderHeight;
} else {
groupedRows[currentGroupedRowIndex].push(row);
}
// Reduce available height by the row height.
availablePageHeight -= rowHeight;
}
return groupedRows;
};
type RenderTablesOptions = {
groupedRows: Konva.Group[][];
columnWidths: ColumnWidths;
i18n: I18n;
};
const renderTables = (options: RenderTablesOptions) => {
const { groupedRows, columnWidths, i18n } = options;
const tables: Konva.Group[] = [];
// Render the rows for each page.
for (const rows of groupedRows) {
const table = new Konva.Group();
const tableHeader = renderRowHeader({ columnWidths, i18n });
table.add(tableHeader);
for (const row of rows) {
row.setAttrs({
x: 0,
y: table.getClientRect().height,
} satisfies Partial<Konva.GroupConfig>);
table.add(row);
}
// Add table background and border.
const tableClientRect = table.getClientRect();
const cardRect = new Konva.Rect({
x: tableClientRect.x,
y: tableClientRect.y,
width: tableClientRect.width,
height: tableClientRect.height,
stroke: '#e5e7eb',
strokeWidth: 1.5,
cornerRadius: 8,
});
table.add(cardRect);
tables.push(table);
}
return tables;
};
export async function renderCertificate({
recipients,
qrToken,
hidePoweredBy,
i18n,
envelopeOwner,
pageWidth,
pageHeight,
}: GenerateCertificateOptions) {
const fontPath = path.join(process.cwd(), 'public/fonts');
// eslint-disable-next-line react-hooks/rules-of-hooks
FontLibrary.use({
['Caveat']: [path.join(fontPath, 'caveat.ttf')],
['Inter']: [path.join(fontPath, 'inter-variablefont_opsz,wght.ttf')],
});
const minimumMargin = 10;
const tableWidth = Math.min(pageWidth - minimumMargin * 2, contentMaxWidth);
const tableContentWidth = tableWidth - rowPadding * 2;
const margin = (pageWidth - tableWidth) / 2;
const columnOneWidth = (tableContentWidth * columnWidthPercentages[0]) / 100;
const columnTwoWidth = (tableContentWidth * columnWidthPercentages[1]) / 100;
const columnThreeWidth = (tableContentWidth * columnWidthPercentages[2]) / 100;
const columnWidths: ColumnWidths = [columnOneWidth, columnTwoWidth, columnThreeWidth];
// Helper to render a Konva stage to a PNG buffer
const stage = new Konva.Stage({ width: pageWidth, height: pageHeight });
const maxTableHeight = pageHeight - pageTopMargin - pageBottomMargin;
const groupedRows = groupRowsIntoPages({
recipients,
maxHeight: maxTableHeight,
columnWidths,
i18n,
envelopeOwner,
});
const tables = renderTables({ groupedRows, columnWidths, i18n });
const brandingGroup = await renderBranding({ qrToken, i18n });
const brandingRect = brandingGroup.getClientRect();
const brandingTopPadding = 24;
const pages: Uint8Array[] = [];
let isQrPlaced = false;
// Add a table to each page.
for (const [index, table] of tables.entries()) {
stage.destroyChildren();
const page = new Konva.Layer();
const group = new Konva.Group();
const titleText = new Konva.Text({
x: margin,
y: 0,
height: pageTopMargin,
verticalAlign: 'middle',
text: i18n._(msg`Signing Certificate`),
fontFamily: 'Inter',
fontSize: titleFontSize,
fontStyle: '700',
});
table.setAttrs({
x: margin,
y: pageTopMargin,
} satisfies Partial<Konva.GroupConfig>);
group.add(titleText);
group.add(table);
// Add QR code and branding on the last page if there is space.
if (index === tables.length - 1 && !hidePoweredBy) {
const remainingHeight = pageHeight - group.getClientRect().height - pageBottomMargin;
if (brandingRect.height + brandingTopPadding <= remainingHeight) {
brandingGroup.setAttrs({
x: pageWidth - brandingRect.width - margin,
y: group.getClientRect().height + brandingTopPadding,
} satisfies Partial<Konva.GroupConfig>);
page.add(brandingGroup);
isQrPlaced = true;
}
}
page.add(group);
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 QR code if it hasn't been placed yet.
if (!hidePoweredBy && !isQrPlaced) {
const page = new Konva.Layer();
brandingGroup.setAttrs({
x: pageWidth - brandingRect.width - margin,
y: pageTopMargin / 2, // Less padding since there's nothing else on this page.
} satisfies Partial<Konva.GroupConfig>);
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));
}
return pages;
}

View File

@@ -2793,10 +2793,6 @@ msgstr "Erstellt am {0}"
msgid "CSV Structure"
msgstr "CSV-Struktur"
#: apps/remix/app/routes/_authenticated+/admin+/stats.tsx
msgid "Cumulative MAU (signed in)"
msgstr "Kumulative MAU (angemeldet)"
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
msgid "Current"
msgstr "Aktuell"

View File

@@ -2788,10 +2788,6 @@ msgstr "Created on {0}"
msgid "CSV Structure"
msgstr "CSV Structure"
#: apps/remix/app/routes/_authenticated+/admin+/stats.tsx
msgid "Cumulative MAU (signed in)"
msgstr "Cumulative MAU (signed in)"
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
msgid "Current"
msgstr "Current"

View File

@@ -2793,10 +2793,6 @@ msgstr "Creado el {0}"
msgid "CSV Structure"
msgstr "Estructura CSV"
#: apps/remix/app/routes/_authenticated+/admin+/stats.tsx
msgid "Cumulative MAU (signed in)"
msgstr "MAU acumulativo (con sesión iniciada)"
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
msgid "Current"
msgstr "Actual"

View File

@@ -2793,10 +2793,6 @@ msgstr "Créé le {0}"
msgid "CSV Structure"
msgstr "Structure CSV"
#: apps/remix/app/routes/_authenticated+/admin+/stats.tsx
msgid "Cumulative MAU (signed in)"
msgstr "MAU cumulatif (connecté)"
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
msgid "Current"
msgstr "Actuel"

View File

@@ -2793,10 +2793,6 @@ msgstr "Creato il {0}"
msgid "CSV Structure"
msgstr "Struttura CSV"
#: apps/remix/app/routes/_authenticated+/admin+/stats.tsx
msgid "Cumulative MAU (signed in)"
msgstr "MAU cumulativi (autenticati)"
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
msgid "Current"
msgstr "Corrente"

View File

@@ -2793,10 +2793,6 @@ msgstr "作成日 {0}"
msgid "CSV Structure"
msgstr "CSV 構造"
#: apps/remix/app/routes/_authenticated+/admin+/stats.tsx
msgid "Cumulative MAU (signed in)"
msgstr "累積 MAUサインイン済み"
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
msgid "Current"
msgstr "現在"

View File

@@ -2793,10 +2793,6 @@ msgstr "{0}에 생성됨"
msgid "CSV Structure"
msgstr "CSV 구조"
#: apps/remix/app/routes/_authenticated+/admin+/stats.tsx
msgid "Cumulative MAU (signed in)"
msgstr "누적 MAU(로그인 기준)"
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
msgid "Current"
msgstr "현재"

View File

@@ -2793,10 +2793,6 @@ msgstr "Gemaakt op {0}"
msgid "CSV Structure"
msgstr "CSV-Structuur"
#: apps/remix/app/routes/_authenticated+/admin+/stats.tsx
msgid "Cumulative MAU (signed in)"
msgstr "Cumulatieve MAU (ingelogd)"
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
msgid "Current"
msgstr "Huidig"

View File

@@ -2793,10 +2793,6 @@ msgstr "Utworzono {0}"
msgid "CSV Structure"
msgstr "Struktura CSV"
#: apps/remix/app/routes/_authenticated+/admin+/stats.tsx
msgid "Cumulative MAU (signed in)"
msgstr "Łączna liczba MAU (zalogowani)"
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
msgid "Current"
msgstr "Obecna"

View File

@@ -2769,10 +2769,6 @@ msgstr "Criado em {0}"
msgid "CSV Structure"
msgstr "Estrutura do CSV"
#: apps/remix/app/routes/_authenticated+/admin+/stats.tsx
msgid "Cumulative MAU (signed in)"
msgstr "MAU acumulado (logados)"
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
msgid "Current"
msgstr "Atual"

View File

@@ -2298,10 +2298,6 @@ msgstr "Krijuar më {0}"
msgid "CSV Structure"
msgstr "Struktura CSV"
#: apps/remix/app/routes/_authenticated+/admin+/stats.tsx
msgid "Cumulative MAU (signed in)"
msgstr ""
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
msgid "Current"
msgstr "Aktual"

View File

@@ -2793,10 +2793,6 @@ msgstr "创建于 {0}"
msgid "CSV Structure"
msgstr "CSV 结构"
#: apps/remix/app/routes/_authenticated+/admin+/stats.tsx
msgid "Cumulative MAU (signed in)"
msgstr "累计月活跃用户(已登录)"
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
msgid "Current"
msgstr "当前"

View File

@@ -745,5 +745,3 @@ export type DocumentAuditLogByType<T = TDocumentAuditLog['type']> = Extract<
TDocumentAuditLog,
{ type: T }
>;
export type TDocumentAuditLogBaseSchema = z.infer<typeof ZDocumentAuditLogBaseSchema>;

View File

@@ -290,12 +290,11 @@ export const diffDocumentMetaChanges = (
* Provide a userId to prefix the action with the user, example 'X did Y'.
*/
export const formatDocumentAuditLogAction = (
i18n: I18n,
_: I18n['_'],
auditLog: TDocumentAuditLog,
userId?: number,
) => {
const prefix =
userId === auditLog.userId ? i18n._(msg`You`) : auditLog.name || auditLog.email || '';
const prefix = userId === auditLog.userId ? _(msg`You`) : auditLog.name || auditLog.email || '';
const description = match(auditLog)
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_CREATED }, () => ({
@@ -453,7 +452,7 @@ export const formatDocumentAuditLogAction = (
identified: msg`${prefix} moved the document to team`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED }, ({ data }) => {
const userName = prefix || i18n._(msg`Recipient`);
const userName = prefix || _(msg`Recipient`);
const result = match(data.recipientRole)
.with(RecipientRole.SIGNER, () => msg`${userName} signed the document`)
@@ -468,7 +467,7 @@ export const formatDocumentAuditLogAction = (
};
})
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED }, ({ data }) => {
const userName = prefix || i18n._(msg`Recipient`);
const userName = prefix || _(msg`Recipient`);
const result = msg`${userName} rejected the document`;
@@ -478,7 +477,7 @@ export const formatDocumentAuditLogAction = (
};
})
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_REQUESTED }, ({ data }) => {
const userName = prefix || i18n._(msg`Recipient`);
const userName = prefix || _(msg`Recipient`);
const result = msg`${userName} requested a 2FA token for the document`;
@@ -488,7 +487,7 @@ export const formatDocumentAuditLogAction = (
};
})
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_VALIDATED }, ({ data }) => {
const userName = prefix || i18n._(msg`Recipient`);
const userName = prefix || _(msg`Recipient`);
const result = msg`${userName} validated a 2FA token for the document`;
@@ -498,7 +497,7 @@ export const formatDocumentAuditLogAction = (
};
})
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_FAILED }, ({ data }) => {
const userName = prefix || i18n._(msg`Recipient`);
const userName = prefix || _(msg`Recipient`);
const result = msg`${userName} failed to validate a 2FA token for the document`;
@@ -535,6 +534,6 @@ export const formatDocumentAuditLogAction = (
return {
prefix,
description: i18n._(prefix ? description.identified : description.anonymous),
description: _(prefix ? description.identified : description.anonymous),
};
};

View File

@@ -67,7 +67,7 @@ export const seedBlankDocument = async (
teamId: number,
options: CreateDocumentOptions = {},
) => {
const { key, createDocumentOptions = {}, internalVersion = 1 } = options;
const { key, createDocumentOptions = {} } = options;
const documentData = await prisma.documentData.create({
data: {
@@ -87,7 +87,7 @@ export const seedBlankDocument = async (
data: {
id: prefixedId('envelope'),
secondaryId: documentId.formattedDocumentId,
internalVersion,
internalVersion: 1,
type: EnvelopeType.DOCUMENT,
documentMetaId: documentMeta.id,
source: DocumentSource.DOCUMENT,
@@ -287,7 +287,7 @@ export const seedDraftDocument = async (
recipients: (User | string)[],
options: CreateDocumentOptions = {},
) => {
const { key, createDocumentOptions = {}, internalVersion = 1 } = options;
const { key, createDocumentOptions = {} } = options;
const documentData = await prisma.documentData.create({
data: {
@@ -307,7 +307,7 @@ export const seedDraftDocument = async (
data: {
id: prefixedId('envelope'),
secondaryId: documentId.formattedDocumentId,
internalVersion,
internalVersion: 1,
type: EnvelopeType.DOCUMENT,
documentMetaId: documentMeta.id,
source: DocumentSource.DOCUMENT,
@@ -372,7 +372,6 @@ export const seedDraftDocument = async (
type CreateDocumentOptions = {
key?: string | number;
createDocumentOptions?: Partial<Prisma.EnvelopeUncheckedCreateInput>;
internalVersion?: number;
};
export const seedPendingDocument = async (
@@ -381,7 +380,7 @@ export const seedPendingDocument = async (
recipients: (User | string)[],
options: CreateDocumentOptions = {},
) => {
const { key, createDocumentOptions = {}, internalVersion = 1 } = options;
const { key, createDocumentOptions = {} } = options;
const documentData = await prisma.documentData.create({
data: {
@@ -401,7 +400,7 @@ export const seedPendingDocument = async (
data: {
id: prefixedId('envelope'),
secondaryId: documentId.formattedDocumentId,
internalVersion,
internalVersion: 1,
type: EnvelopeType.DOCUMENT,
documentMetaId: documentMeta.id,
source: DocumentSource.DOCUMENT,
@@ -618,7 +617,7 @@ export const seedCompletedDocument = async (
recipients: (User | string)[],
options: CreateDocumentOptions = {},
) => {
const { key, createDocumentOptions = {}, internalVersion = 1 } = options;
const { key, createDocumentOptions = {} } = options;
const documentData = await prisma.documentData.create({
data: {
@@ -638,7 +637,7 @@ export const seedCompletedDocument = async (
data: {
id: prefixedId('envelope'),
secondaryId: documentId.formattedDocumentId,
internalVersion,
internalVersion: 1,
type: EnvelopeType.DOCUMENT,
documentMetaId: documentMeta.id,
source: DocumentSource.DOCUMENT,