mirror of
https://github.com/documenso/documenso.git
synced 2025-11-27 14:59:10 +10:00
Compare commits
2 Commits
fix/migrat
...
feat/find-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5b7522ea0 | ||
|
|
4d29a66ba1 |
1
.npmrc
1
.npmrc
@@ -1,2 +1,3 @@
|
||||
auto-install-peers = true
|
||||
legacy-peer-deps = true
|
||||
prefer-dedupe = true
|
||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -17,5 +17,6 @@
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
}
|
||||
},
|
||||
"prisma.pinToPrisma6": true
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import { getOrganisationDetailedInsights } from '@documenso/lib/server-only/admin/get-organisation-detailed-insights';
|
||||
import type { DateRange } from '@documenso/lib/types/search-params';
|
||||
import { getAdminOrganisation } from '@documenso/trpc/server/admin-router/get-admin-organisation';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import { OrganisationInsightsTable } from '~/components/tables/organisation-insights-table';
|
||||
|
||||
@@ -42,17 +38,12 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
}
|
||||
|
||||
export default function OrganisationInsights({ loaderData }: Route.ComponentProps) {
|
||||
const { insights, page, perPage, dateRange, view, organisationName, organisationId } = loaderData;
|
||||
const { insights, page, perPage, dateRange, view, organisationName } = loaderData;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-4xl font-semibold">{organisationName}</h2>
|
||||
<Button variant="outline" asChild>
|
||||
<Link to={`/admin/organisations/${organisationId}`}>
|
||||
<Trans>Manage organisation</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
<OrganisationInsightsTable
|
||||
|
||||
@@ -44,7 +44,7 @@ export async function loader({ request }: Route.LoaderArgs) {
|
||||
const typedOrganisations: OrganisationOverview[] = organisations.map((item) => ({
|
||||
id: String(item.id),
|
||||
name: item.name || '',
|
||||
signingVolume: item.signingVolume || 0,
|
||||
signingVolume: item.signingVolume,
|
||||
createdAt: item.createdAt || new Date(),
|
||||
customerId: item.customerId || '',
|
||||
subscriptionStatus: item.subscriptionStatus,
|
||||
|
||||
@@ -162,13 +162,7 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
|
||||
<SettingsHeader
|
||||
title={t`Manage organisation`}
|
||||
subtitle={t`Manage the ${organisation.name} organisation`}
|
||||
>
|
||||
<Button variant="outline" asChild>
|
||||
<Link to={`/admin/organisation-insights/${organisationId}`}>
|
||||
<Trans>View insights</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
</SettingsHeader>
|
||||
/>
|
||||
|
||||
<GenericOrganisationAdminForm organisation={organisation} />
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"commitlint": "commitlint --edit",
|
||||
"clean": "turbo run clean && rimraf node_modules",
|
||||
"d": "npm run dx && npm run translate:compile && npm run dev",
|
||||
"dx": "npm ci && npm run dx:up && npm run prisma:migrate-dev && npm run prisma:seed",
|
||||
"dx": "npm i && npm run dx:up && npm run prisma:migrate-dev && npm run prisma:seed",
|
||||
"dx:up": "docker compose -f docker/development/compose.yml up -d",
|
||||
"dx:down": "docker compose -f docker/development/compose.yml down",
|
||||
"ci": "turbo run build --filter=@documenso/remix && turbo run test:e2e",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { type APIRequestContext, expect, test } from '@playwright/test';
|
||||
import type { Team, User } from '@prisma/client';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
@@ -24,6 +24,7 @@ import type {
|
||||
TCreateEnvelopeResponse,
|
||||
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
|
||||
import type { TCreateEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/envelope-recipients/create-envelope-recipients.types';
|
||||
import type { TFindEnvelopesResponse } from '@documenso/trpc/server/envelope-router/find-envelopes.types';
|
||||
import type { TGetEnvelopeResponse } from '@documenso/trpc/server/envelope-router/get-envelope.types';
|
||||
import type { TUpdateEnvelopeRequest } from '@documenso/trpc/server/envelope-router/update-envelope.types';
|
||||
|
||||
@@ -164,6 +165,9 @@ test.describe('API V2 Envelopes', () => {
|
||||
positionY: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
fieldMeta: {
|
||||
type: 'signature',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: FieldType.SIGNATURE,
|
||||
@@ -173,6 +177,9 @@ test.describe('API V2 Envelopes', () => {
|
||||
positionY: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
fieldMeta: {
|
||||
type: 'signature',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -557,4 +564,198 @@ test.describe('API V2 Envelopes', () => {
|
||||
userEmail: userA.email,
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Envelope find endpoint', () => {
|
||||
const createEnvelope = async (
|
||||
request: APIRequestContext,
|
||||
token: string,
|
||||
payload: TCreateEnvelopePayload,
|
||||
) => {
|
||||
const formData = new FormData();
|
||||
formData.append('payload', JSON.stringify(payload));
|
||||
|
||||
const pdfData = fs.readFileSync(
|
||||
path.join(__dirname, '../../../../../assets/field-font-alignment.pdf'),
|
||||
);
|
||||
formData.append('files', new File([pdfData], 'test.pdf', { type: 'application/pdf' }));
|
||||
|
||||
const res = await request.post(`${baseUrl}/envelope/create`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
multipart: formData,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
return (await res.json()) as TCreateEnvelopeResponse;
|
||||
};
|
||||
|
||||
test('should find envelopes with pagination', async ({ request }) => {
|
||||
// Create 3 envelopes
|
||||
await createEnvelope(request, tokenA, {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
title: 'Document 1',
|
||||
});
|
||||
await createEnvelope(request, tokenA, {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
title: 'Document 2',
|
||||
});
|
||||
await createEnvelope(request, tokenA, {
|
||||
type: EnvelopeType.TEMPLATE,
|
||||
title: 'Template 1',
|
||||
});
|
||||
|
||||
// Find all envelopes
|
||||
const res = await request.get(`${baseUrl}/envelope`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
const response = (await res.json()) as TFindEnvelopesResponse;
|
||||
|
||||
expect(response.data.length).toBe(3);
|
||||
expect(response.count).toBe(3);
|
||||
expect(response.currentPage).toBe(1);
|
||||
expect(response.totalPages).toBe(1);
|
||||
|
||||
// Test pagination
|
||||
const paginatedRes = await request.get(`${baseUrl}/envelope?perPage=2&page=1`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
});
|
||||
|
||||
expect(paginatedRes.ok()).toBeTruthy();
|
||||
const paginatedResponse = (await paginatedRes.json()) as TFindEnvelopesResponse;
|
||||
|
||||
expect(paginatedResponse.data.length).toBe(2);
|
||||
expect(paginatedResponse.count).toBe(3);
|
||||
expect(paginatedResponse.totalPages).toBe(2);
|
||||
});
|
||||
|
||||
test('should filter envelopes by type', async ({ request }) => {
|
||||
await createEnvelope(request, tokenA, {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
title: 'Document Only',
|
||||
});
|
||||
await createEnvelope(request, tokenA, {
|
||||
type: EnvelopeType.TEMPLATE,
|
||||
title: 'Template Only',
|
||||
});
|
||||
|
||||
// Filter by DOCUMENT type
|
||||
const documentRes = await request.get(`${baseUrl}/envelope?type=DOCUMENT`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
});
|
||||
|
||||
expect(documentRes.ok()).toBeTruthy();
|
||||
const documentResponse = (await documentRes.json()) as TFindEnvelopesResponse;
|
||||
|
||||
expect(documentResponse.data.every((e) => e.type === EnvelopeType.DOCUMENT)).toBe(true);
|
||||
|
||||
// Filter by TEMPLATE type
|
||||
const templateRes = await request.get(`${baseUrl}/envelope?type=TEMPLATE`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
});
|
||||
|
||||
expect(templateRes.ok()).toBeTruthy();
|
||||
const templateResponse = (await templateRes.json()) as TFindEnvelopesResponse;
|
||||
|
||||
expect(templateResponse.data.every((e) => e.type === EnvelopeType.TEMPLATE)).toBe(true);
|
||||
});
|
||||
|
||||
test('should filter envelopes by status', async ({ request }) => {
|
||||
await createEnvelope(request, tokenA, {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
title: 'Draft Document',
|
||||
});
|
||||
|
||||
// Filter by DRAFT status (default for new envelopes)
|
||||
const res = await request.get(`${baseUrl}/envelope?status=DRAFT`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
const response = (await res.json()) as TFindEnvelopesResponse;
|
||||
|
||||
expect(response.data.every((e) => e.status === DocumentStatus.DRAFT)).toBe(true);
|
||||
});
|
||||
|
||||
test('should search envelopes by query', async ({ request }) => {
|
||||
await createEnvelope(request, tokenA, {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
title: 'Unique Searchable Title',
|
||||
});
|
||||
await createEnvelope(request, tokenA, {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
title: 'Another Document',
|
||||
});
|
||||
|
||||
const res = await request.get(`${baseUrl}/envelope?query=Unique%20Searchable`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
const response = (await res.json()) as TFindEnvelopesResponse;
|
||||
|
||||
expect(response.data.length).toBe(1);
|
||||
expect(response.data[0].title).toBe('Unique Searchable Title');
|
||||
});
|
||||
|
||||
test('should not return envelopes from other users', async ({ request }) => {
|
||||
// Create envelope for userA
|
||||
await createEnvelope(request, tokenA, {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
title: 'UserA Document',
|
||||
});
|
||||
|
||||
// Create envelope for userB
|
||||
await createEnvelope(request, tokenB, {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
title: 'UserB Document',
|
||||
});
|
||||
|
||||
// userA should only see their own envelopes
|
||||
const resA = await request.get(`${baseUrl}/envelope`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
});
|
||||
|
||||
expect(resA.ok()).toBeTruthy();
|
||||
const responseA = (await resA.json()) as TFindEnvelopesResponse;
|
||||
|
||||
expect(responseA.data.every((e) => e.title !== 'UserB Document')).toBe(true);
|
||||
|
||||
// userB should only see their own envelopes
|
||||
const resB = await request.get(`${baseUrl}/envelope`, {
|
||||
headers: { Authorization: `Bearer ${tokenB}` },
|
||||
});
|
||||
|
||||
expect(resB.ok()).toBeTruthy();
|
||||
const responseB = (await resB.json()) as TFindEnvelopesResponse;
|
||||
|
||||
expect(responseB.data.every((e) => e.title !== 'UserA Document')).toBe(true);
|
||||
});
|
||||
|
||||
test('should return envelope with expected schema fields', async ({ request }) => {
|
||||
await createEnvelope(request, tokenA, {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
title: 'Schema Test Document',
|
||||
});
|
||||
|
||||
const res = await request.get(`${baseUrl}/envelope`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
const response = (await res.json()) as TFindEnvelopesResponse;
|
||||
|
||||
const envelope = response.data.find((e) => e.title === 'Schema Test Document');
|
||||
|
||||
expect(envelope).toBeDefined();
|
||||
expect(envelope?.id).toBeDefined();
|
||||
expect(envelope?.type).toBe(EnvelopeType.DOCUMENT);
|
||||
expect(envelope?.status).toBe(DocumentStatus.DRAFT);
|
||||
expect(envelope?.recipients).toBeDefined();
|
||||
expect(envelope?.user).toBeDefined();
|
||||
expect(envelope?.team).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 163 KiB After Width: | Height: | Size: 144 KiB |
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -116,28 +116,28 @@ async function getTeamInsights(
|
||||
): Promise<OrganisationDetailedInsights> {
|
||||
const teamsQuery = kyselyPrisma.$kysely
|
||||
.selectFrom('Team as t')
|
||||
.leftJoin('Envelope as e', (join) =>
|
||||
join
|
||||
.onRef('t.id', '=', 'e.teamId')
|
||||
.on('e.deletedAt', 'is', null)
|
||||
.on('e.type', '=', sql.lit(EnvelopeType.DOCUMENT)),
|
||||
)
|
||||
.leftJoin('TeamGroup as tg', 'tg.teamId', 't.id')
|
||||
.leftJoin('OrganisationGroup as og', 'og.id', 'tg.organisationGroupId')
|
||||
.leftJoin('OrganisationGroupMember as ogm', 'ogm.groupId', 'og.id')
|
||||
.leftJoin('OrganisationMember as om', 'om.id', 'ogm.organisationMemberId')
|
||||
.where('t.organisationId', '=', organisationId)
|
||||
.select((eb) => [
|
||||
't.id',
|
||||
't.name',
|
||||
't.createdAt',
|
||||
eb
|
||||
.selectFrom('TeamGroup as tg')
|
||||
.innerJoin('OrganisationGroup as og', 'og.id', 'tg.organisationGroupId')
|
||||
.innerJoin('OrganisationGroupMember as ogm', 'ogm.groupId', 'og.id')
|
||||
.innerJoin('OrganisationMember as om', 'om.id', 'ogm.organisationMemberId')
|
||||
.whereRef('tg.teamId', '=', 't.id')
|
||||
.select(sql<number>`count(distinct om."userId")`.as('count'))
|
||||
.as('memberCount'),
|
||||
eb
|
||||
.selectFrom('Envelope as e')
|
||||
.whereRef('e.teamId', '=', 't.id')
|
||||
.where('e.deletedAt', 'is', null)
|
||||
.where('e.type', '=', sql.lit(EnvelopeType.DOCUMENT))
|
||||
.$if(!!createdAtFrom, (qb) => qb.where('e.createdAt', '>=', createdAtFrom!))
|
||||
.select(sql<number>`count(e.id)`.as('count'))
|
||||
.as('documentCount'),
|
||||
.select([
|
||||
't.id as id',
|
||||
't.name as name',
|
||||
't.createdAt as createdAt',
|
||||
sql<number>`COUNT(DISTINCT om."userId")`.as('memberCount'),
|
||||
(createdAtFrom
|
||||
? sql<number>`COUNT(DISTINCT CASE WHEN e.id IS NOT NULL AND e."createdAt" >= ${createdAtFrom} THEN e.id END)`
|
||||
: sql<number>`COUNT(DISTINCT e.id)`
|
||||
).as('documentCount'),
|
||||
])
|
||||
.groupBy(['t.id', 't.name', 't.createdAt'])
|
||||
.orderBy('documentCount', 'desc')
|
||||
.limit(perPage)
|
||||
.offset(offset);
|
||||
@@ -164,38 +164,48 @@ async function getUserInsights(
|
||||
perPage: number,
|
||||
createdAtFrom: Date | null,
|
||||
): Promise<OrganisationDetailedInsights> {
|
||||
const usersQuery = kyselyPrisma.$kysely
|
||||
const usersBase = kyselyPrisma.$kysely
|
||||
.selectFrom('OrganisationMember as om')
|
||||
.innerJoin('User as u', 'u.id', 'om.userId')
|
||||
.where('om.organisationId', '=', organisationId)
|
||||
.select((eb) => [
|
||||
'u.id',
|
||||
'u.name',
|
||||
'u.email',
|
||||
'u.createdAt',
|
||||
eb
|
||||
.selectFrom('Envelope as e')
|
||||
.innerJoin('Team as t', 't.id', 'e.teamId')
|
||||
.whereRef('e.userId', '=', 'u.id')
|
||||
.where('t.organisationId', '=', organisationId)
|
||||
.where('e.deletedAt', 'is', null)
|
||||
.where('e.type', '=', sql.lit(EnvelopeType.DOCUMENT))
|
||||
.$if(!!createdAtFrom, (qb) => qb.where('e.createdAt', '>=', createdAtFrom!))
|
||||
.select(sql<number>`count(e.id)`.as('count'))
|
||||
.as('documentCount'),
|
||||
eb
|
||||
.selectFrom('Recipient as r')
|
||||
.innerJoin('Envelope as e', 'e.id', 'r.envelopeId')
|
||||
.innerJoin('Team as t', 't.id', 'e.teamId')
|
||||
.whereRef('r.email', '=', 'u.email')
|
||||
.where('r.signedAt', 'is not', null)
|
||||
.where('t.organisationId', '=', organisationId)
|
||||
.where('e.deletedAt', 'is', null)
|
||||
.where('e.type', '=', sql.lit(EnvelopeType.DOCUMENT))
|
||||
.$if(!!createdAtFrom, (qb) => qb.where('e.createdAt', '>=', createdAtFrom!))
|
||||
.select(sql<number>`count(e.id)`.as('count'))
|
||||
.as('signedDocumentCount'),
|
||||
.leftJoin('Envelope as e', (join) =>
|
||||
join
|
||||
.onRef('e.userId', '=', 'u.id')
|
||||
.on('e.deletedAt', 'is', null)
|
||||
.on('e.type', '=', sql.lit(EnvelopeType.DOCUMENT)),
|
||||
)
|
||||
.leftJoin('Team as td', (join) =>
|
||||
join.onRef('td.id', '=', 'e.teamId').on('td.organisationId', '=', organisationId),
|
||||
)
|
||||
.leftJoin('Recipient as r', (join) =>
|
||||
join.onRef('r.email', '=', 'u.email').on('r.signedAt', 'is not', null),
|
||||
)
|
||||
.leftJoin('Envelope as se', (join) =>
|
||||
join
|
||||
.onRef('se.id', '=', 'r.envelopeId')
|
||||
.on('se.deletedAt', 'is', null)
|
||||
.on('se.type', '=', sql.lit(EnvelopeType.DOCUMENT)),
|
||||
)
|
||||
.leftJoin('Team as ts', (join) =>
|
||||
join.onRef('ts.id', '=', 'se.teamId').on('ts.organisationId', '=', organisationId),
|
||||
);
|
||||
|
||||
const usersQuery = usersBase
|
||||
.select([
|
||||
'u.id as id',
|
||||
'u.name as name',
|
||||
'u.email as email',
|
||||
'u.createdAt as createdAt',
|
||||
(createdAtFrom
|
||||
? sql<number>`COUNT(DISTINCT CASE WHEN e.id IS NOT NULL AND td.id IS NOT NULL AND e."createdAt" >= ${createdAtFrom} THEN e.id END)`
|
||||
: sql<number>`COUNT(DISTINCT CASE WHEN td.id IS NOT NULL THEN e.id END)`
|
||||
).as('documentCount'),
|
||||
(createdAtFrom
|
||||
? sql<number>`COUNT(DISTINCT CASE WHEN e.id IS NOT NULL AND td.id IS NOT NULL AND e.status = 'COMPLETED' AND e."createdAt" >= ${createdAtFrom} THEN e.id END)`
|
||||
: sql<number>`COUNT(DISTINCT CASE WHEN e.id IS NOT NULL AND td.id IS NOT NULL AND e.status = 'COMPLETED' THEN e.id END)`
|
||||
).as('signedDocumentCount'),
|
||||
])
|
||||
.groupBy(['u.id', 'u.name', 'u.email', 'u.createdAt'])
|
||||
.orderBy('u.createdAt', 'desc')
|
||||
.limit(perPage)
|
||||
.offset(offset);
|
||||
@@ -282,51 +292,72 @@ async function getOrganisationSummary(
|
||||
organisationId: string,
|
||||
createdAtFrom: Date | null,
|
||||
): Promise<OrganisationSummary> {
|
||||
const teamCountQuery = kyselyPrisma.$kysely
|
||||
.selectFrom('Team')
|
||||
.where('organisationId', '=', organisationId)
|
||||
.select(sql<number>`count(id)`.as('count'))
|
||||
.executeTakeFirst();
|
||||
|
||||
const memberCountQuery = kyselyPrisma.$kysely
|
||||
.selectFrom('OrganisationMember')
|
||||
.where('organisationId', '=', organisationId)
|
||||
.select(sql<number>`count(id)`.as('count'))
|
||||
.executeTakeFirst();
|
||||
|
||||
const envelopeStatsQuery = kyselyPrisma.$kysely
|
||||
.selectFrom('Envelope as e')
|
||||
.innerJoin('Team as t', 't.id', 'e.teamId')
|
||||
.where('t.organisationId', '=', organisationId)
|
||||
.where('e.deletedAt', 'is', null)
|
||||
.where('e.type', '=', sql.lit(EnvelopeType.DOCUMENT))
|
||||
const summaryQuery = kyselyPrisma.$kysely
|
||||
.selectFrom('Organisation as o')
|
||||
.where('o.id', '=', organisationId)
|
||||
.select([
|
||||
sql<number>`count(e.id)`.as('totalDocuments'),
|
||||
sql<number>`count(case when e.status in ('DRAFT', 'PENDING') then 1 end)`.as(
|
||||
'activeDocuments',
|
||||
sql<number>`(SELECT COUNT(DISTINCT t2.id) FROM "Team" AS t2 WHERE t2."organisationId" = o.id)`.as(
|
||||
'totalTeams',
|
||||
),
|
||||
sql<number>`count(case when e.status = 'COMPLETED' then 1 end)`.as('completedDocuments'),
|
||||
sql<number>`count(case when e.status = 'COMPLETED' then 1 end)`.as('volumeAllTime'),
|
||||
sql<number>`(SELECT COUNT(DISTINCT om2."userId") FROM "OrganisationMember" AS om2 WHERE om2."organisationId" = o.id)`.as(
|
||||
'totalMembers',
|
||||
),
|
||||
sql<number>`(
|
||||
SELECT COUNT(DISTINCT e2.id)
|
||||
FROM "Envelope" AS e2
|
||||
INNER JOIN "Team" AS t2 ON t2.id = e2."teamId"
|
||||
WHERE t2."organisationId" = o.id AND e2."deletedAt" IS NULL AND e2.type = 'DOCUMENT'
|
||||
)`.as('totalDocuments'),
|
||||
sql<number>`(
|
||||
SELECT COUNT(DISTINCT e2.id)
|
||||
FROM "Envelope" AS e2
|
||||
INNER JOIN "Team" AS t2 ON t2.id = e2."teamId"
|
||||
WHERE t2."organisationId" = o.id AND e2."deletedAt" IS NULL AND e2.type = 'DOCUMENT' AND e2.status IN ('DRAFT', 'PENDING')
|
||||
)`.as('activeDocuments'),
|
||||
sql<number>`(
|
||||
SELECT COUNT(DISTINCT e2.id)
|
||||
FROM "Envelope" AS e2
|
||||
INNER JOIN "Team" AS t2 ON t2.id = e2."teamId"
|
||||
WHERE t2."organisationId" = o.id AND e2."deletedAt" IS NULL AND e2.type = 'DOCUMENT' AND e2.status = 'COMPLETED'
|
||||
)`.as('completedDocuments'),
|
||||
(createdAtFrom
|
||||
? sql<number>`count(case when e.status = 'COMPLETED' and e."createdAt" >= ${createdAtFrom} then 1 end)`
|
||||
: sql<number>`count(case when e.status = 'COMPLETED' then 1 end)`
|
||||
? sql<number>`(
|
||||
SELECT COUNT(DISTINCT e2.id)
|
||||
FROM "Envelope" AS e2
|
||||
INNER JOIN "Team" AS t2 ON t2.id = e2."teamId"
|
||||
WHERE t2."organisationId" = o.id
|
||||
AND e2."deletedAt" IS NULL
|
||||
AND e2.type = 'DOCUMENT'
|
||||
AND e2.status = 'COMPLETED'
|
||||
AND e2."createdAt" >= ${createdAtFrom}
|
||||
)`
|
||||
: sql<number>`(
|
||||
SELECT COUNT(DISTINCT e2.id)
|
||||
FROM "Envelope" AS e2
|
||||
INNER JOIN "Team" AS t2 ON t2.id = e2."teamId"
|
||||
WHERE t2."organisationId" = o.id
|
||||
AND e2."deletedAt" IS NULL
|
||||
AND e2.type = 'DOCUMENT'
|
||||
AND e2.status = 'COMPLETED'
|
||||
)`
|
||||
).as('volumeThisPeriod'),
|
||||
])
|
||||
.executeTakeFirst();
|
||||
sql<number>`(
|
||||
SELECT COUNT(DISTINCT e2.id)
|
||||
FROM "Envelope" AS e2
|
||||
INNER JOIN "Team" AS t2 ON t2.id = e2."teamId"
|
||||
WHERE t2."organisationId" = o.id AND e2."deletedAt" IS NULL AND e2.type = 'DOCUMENT' AND e2.status = 'COMPLETED'
|
||||
)`.as('volumeAllTime'),
|
||||
]);
|
||||
|
||||
const [teamCount, memberCount, envelopeStats] = await Promise.all([
|
||||
teamCountQuery,
|
||||
memberCountQuery,
|
||||
envelopeStatsQuery,
|
||||
]);
|
||||
const result = await summaryQuery.executeTakeFirst();
|
||||
|
||||
return {
|
||||
totalTeams: Number(teamCount?.count || 0),
|
||||
totalMembers: Number(memberCount?.count || 0),
|
||||
totalDocuments: Number(envelopeStats?.totalDocuments || 0),
|
||||
activeDocuments: Number(envelopeStats?.activeDocuments || 0),
|
||||
completedDocuments: Number(envelopeStats?.completedDocuments || 0),
|
||||
volumeThisPeriod: Number(envelopeStats?.volumeThisPeriod || 0),
|
||||
volumeAllTime: Number(envelopeStats?.volumeAllTime || 0),
|
||||
totalTeams: Number(result?.totalTeams || 0),
|
||||
totalMembers: Number(result?.totalMembers || 0),
|
||||
totalDocuments: Number(result?.totalDocuments || 0),
|
||||
activeDocuments: Number(result?.activeDocuments || 0),
|
||||
completedDocuments: Number(result?.completedDocuments || 0),
|
||||
volumeThisPeriod: Number(result?.volumeThisPeriod || 0),
|
||||
volumeAllTime: Number(result?.volumeAllTime || 0),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -33,32 +33,25 @@ export async function getSigningVolume({
|
||||
|
||||
let findQuery = kyselyPrisma.$kysely
|
||||
.selectFrom('Organisation as o')
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb('o.name', 'ilike', `%${search}%`),
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('Team as t')
|
||||
.whereRef('t.organisationId', '=', 'o.id')
|
||||
.where('t.name', 'ilike', `%${search}%`),
|
||||
),
|
||||
]),
|
||||
.leftJoin('Team as t', 'o.id', 't.organisationId')
|
||||
.leftJoin('Envelope as e', (join) =>
|
||||
join
|
||||
.onRef('t.id', '=', 'e.teamId')
|
||||
.on('e.status', '=', sql.lit(DocumentStatus.COMPLETED))
|
||||
.on('e.deletedAt', 'is', null)
|
||||
.on('e.type', '=', sql.lit(EnvelopeType.DOCUMENT)),
|
||||
)
|
||||
.select((eb) => [
|
||||
.where((eb) =>
|
||||
eb.or([eb('o.name', 'ilike', `%${search}%`), eb('t.name', 'ilike', `%${search}%`)]),
|
||||
)
|
||||
.select([
|
||||
'o.id as id',
|
||||
'o.createdAt as createdAt',
|
||||
'o.customerId as customerId',
|
||||
sql<string>`COALESCE(o.name, 'Unknown')`.as('name'),
|
||||
eb
|
||||
.selectFrom('Envelope as e')
|
||||
.innerJoin('Team as t', 't.id', 'e.teamId')
|
||||
.whereRef('t.organisationId', '=', 'o.id')
|
||||
.where('e.status', '=', sql.lit(DocumentStatus.COMPLETED))
|
||||
.where('e.deletedAt', 'is', null)
|
||||
.where('e.type', '=', sql.lit(EnvelopeType.DOCUMENT))
|
||||
.select(sql<number>`count(e.id)`.as('count'))
|
||||
.as('signingVolume'),
|
||||
]);
|
||||
sql<number>`COUNT(DISTINCT e.id)`.as('signingVolume'),
|
||||
])
|
||||
.groupBy(['o.id', 'o.name', 'o.customerId']);
|
||||
|
||||
switch (sortBy) {
|
||||
case 'name':
|
||||
@@ -78,18 +71,11 @@ export async function getSigningVolume({
|
||||
|
||||
const countQuery = kyselyPrisma.$kysely
|
||||
.selectFrom('Organisation as o')
|
||||
.leftJoin('Team as t', 'o.id', 't.organisationId')
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb('o.name', 'ilike', `%${search}%`),
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('Team as t')
|
||||
.whereRef('t.organisationId', '=', 'o.id')
|
||||
.where('t.name', 'ilike', `%${search}%`),
|
||||
),
|
||||
]),
|
||||
eb.or([eb('o.name', 'ilike', `%${search}%`), eb('t.name', 'ilike', `%${search}%`)]),
|
||||
)
|
||||
.select(({ fn }) => [fn.countAll().as('count')]);
|
||||
.select(() => [sql<number>`COUNT(DISTINCT o.id)`.as('count')]);
|
||||
|
||||
const [results, [{ count }]] = await Promise.all([findQuery.execute(), countQuery.execute()]);
|
||||
|
||||
@@ -118,77 +104,64 @@ export async function getOrganisationInsights({
|
||||
const offset = Math.max(page - 1, 0) * perPage;
|
||||
|
||||
const now = new Date();
|
||||
let dateCondition = sql<boolean>`1=1`;
|
||||
let dateCondition = sql`1=1`;
|
||||
|
||||
if (startDate && endDate) {
|
||||
dateCondition = sql<boolean>`e."createdAt" >= ${startDate} AND e."createdAt" <= ${endDate}`;
|
||||
dateCondition = sql`e."createdAt" >= ${startDate} AND e."createdAt" <= ${endDate}`;
|
||||
} else {
|
||||
switch (dateRange) {
|
||||
case 'last30days': {
|
||||
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
dateCondition = sql<boolean>`e."createdAt" >= ${thirtyDaysAgo}`;
|
||||
dateCondition = sql`e."createdAt" >= ${thirtyDaysAgo}`;
|
||||
break;
|
||||
}
|
||||
case 'last90days': {
|
||||
const ninetyDaysAgo = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
|
||||
dateCondition = sql<boolean>`e."createdAt" >= ${ninetyDaysAgo}`;
|
||||
dateCondition = sql`e."createdAt" >= ${ninetyDaysAgo}`;
|
||||
break;
|
||||
}
|
||||
case 'lastYear': {
|
||||
const oneYearAgo = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate());
|
||||
dateCondition = sql<boolean>`e."createdAt" >= ${oneYearAgo}`;
|
||||
dateCondition = sql`e."createdAt" >= ${oneYearAgo}`;
|
||||
break;
|
||||
}
|
||||
case 'allTime':
|
||||
default:
|
||||
dateCondition = sql<boolean>`1=1`;
|
||||
dateCondition = sql`1=1`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let findQuery = kyselyPrisma.$kysely
|
||||
.selectFrom('Organisation as o')
|
||||
.leftJoin('Team as t', 'o.id', 't.organisationId')
|
||||
.leftJoin('Envelope as e', (join) =>
|
||||
join
|
||||
.onRef('t.id', '=', 'e.teamId')
|
||||
.on('e.status', '=', sql.lit(DocumentStatus.COMPLETED))
|
||||
.on('e.deletedAt', 'is', null)
|
||||
.on('e.type', '=', sql.lit(EnvelopeType.DOCUMENT)),
|
||||
)
|
||||
.leftJoin('OrganisationMember as om', 'o.id', 'om.organisationId')
|
||||
.leftJoin('Subscription as s', 'o.id', 's.organisationId')
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb('o.name', 'ilike', `%${search}%`),
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('Team as t')
|
||||
.whereRef('t.organisationId', '=', 'o.id')
|
||||
.where('t.name', 'ilike', `%${search}%`),
|
||||
),
|
||||
]),
|
||||
eb.or([eb('o.name', 'ilike', `%${search}%`), eb('t.name', 'ilike', `%${search}%`)]),
|
||||
)
|
||||
.select((eb) => [
|
||||
.select([
|
||||
'o.id as id',
|
||||
'o.createdAt as createdAt',
|
||||
'o.customerId as customerId',
|
||||
sql<string>`COALESCE(o.name, 'Unknown')`.as('name'),
|
||||
sql<number>`COUNT(DISTINCT CASE WHEN e.id IS NOT NULL AND ${dateCondition} THEN e.id END)`.as(
|
||||
'signingVolume',
|
||||
),
|
||||
sql<number>`GREATEST(COUNT(DISTINCT t.id), 1)`.as('teamCount'),
|
||||
sql<number>`COUNT(DISTINCT om."userId")`.as('memberCount'),
|
||||
sql<string>`CASE WHEN s.status IS NOT NULL THEN s.status ELSE NULL END`.as(
|
||||
'subscriptionStatus',
|
||||
),
|
||||
eb
|
||||
.selectFrom('Team as t')
|
||||
.whereRef('t.organisationId', '=', 'o.id')
|
||||
.select(sql<number>`count(t.id)`.as('count'))
|
||||
.as('teamCount'),
|
||||
eb
|
||||
.selectFrom('OrganisationMember as om')
|
||||
.whereRef('om.organisationId', '=', 'o.id')
|
||||
.select(sql<number>`count(om.id)`.as('count'))
|
||||
.as('memberCount'),
|
||||
eb
|
||||
.selectFrom('Envelope as e')
|
||||
.innerJoin('Team as t', 't.id', 'e.teamId')
|
||||
.whereRef('t.organisationId', '=', 'o.id')
|
||||
.where('e.status', '=', sql.lit(DocumentStatus.COMPLETED))
|
||||
.where('e.deletedAt', 'is', null)
|
||||
.where('e.type', '=', sql.lit(EnvelopeType.DOCUMENT))
|
||||
.where(dateCondition)
|
||||
.select(sql<number>`count(e.id)`.as('count'))
|
||||
.as('signingVolume'),
|
||||
]);
|
||||
])
|
||||
.groupBy(['o.id', 'o.name', 'o.customerId', 's.status']);
|
||||
|
||||
switch (sortBy) {
|
||||
case 'name':
|
||||
@@ -208,18 +181,11 @@ export async function getOrganisationInsights({
|
||||
|
||||
const countQuery = kyselyPrisma.$kysely
|
||||
.selectFrom('Organisation as o')
|
||||
.leftJoin('Team as t', 'o.id', 't.organisationId')
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb('o.name', 'ilike', `%${search}%`),
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('Team as t')
|
||||
.whereRef('t.organisationId', '=', 'o.id')
|
||||
.where('t.name', 'ilike', `%${search}%`),
|
||||
),
|
||||
]),
|
||||
eb.or([eb('o.name', 'ilike', `%${search}%`), eb('t.name', 'ilike', `%${search}%`)]),
|
||||
)
|
||||
.select(({ fn }) => [fn.countAll().as('count')]);
|
||||
.select(() => [sql<number>`COUNT(DISTINCT o.id)`.as('count')]);
|
||||
|
||||
const [results, [{ count }]] = await Promise.all([findQuery.execute(), countQuery.execute()]);
|
||||
|
||||
|
||||
202
packages/lib/server-only/envelope/find-envelopes.ts
Normal file
202
packages/lib/server-only/envelope/find-envelopes.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import type {
|
||||
DocumentSource,
|
||||
DocumentStatus,
|
||||
Envelope,
|
||||
EnvelopeType,
|
||||
Prisma,
|
||||
} from '@prisma/client';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
|
||||
import type { FindResultResponse } from '../../types/search-params';
|
||||
import { maskRecipientTokensForDocument } from '../../utils/mask-recipient-tokens-for-document';
|
||||
import { getTeamById } from '../team/get-team';
|
||||
|
||||
export type FindEnvelopesOptions = {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
type?: EnvelopeType;
|
||||
templateId?: number;
|
||||
source?: DocumentSource;
|
||||
status?: DocumentStatus;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
orderBy?: {
|
||||
column: keyof Pick<Envelope, 'createdAt'>;
|
||||
direction: 'asc' | 'desc';
|
||||
};
|
||||
query?: string;
|
||||
folderId?: string;
|
||||
};
|
||||
|
||||
export const findEnvelopes = async ({
|
||||
userId,
|
||||
teamId,
|
||||
type,
|
||||
templateId,
|
||||
source,
|
||||
status,
|
||||
page = 1,
|
||||
perPage = 10,
|
||||
orderBy,
|
||||
query = '',
|
||||
folderId,
|
||||
}: FindEnvelopesOptions) => {
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
|
||||
const team = await getTeamById({
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
const orderByColumn = orderBy?.column ?? 'createdAt';
|
||||
const orderByDirection = orderBy?.direction ?? 'desc';
|
||||
|
||||
const searchFilter: Prisma.EnvelopeWhereInput = query
|
||||
? {
|
||||
OR: [
|
||||
{ title: { contains: query, mode: 'insensitive' } },
|
||||
{ externalId: { contains: query, mode: 'insensitive' } },
|
||||
{ recipients: { some: { name: { contains: query, mode: 'insensitive' } } } },
|
||||
{ recipients: { some: { email: { contains: query, mode: 'insensitive' } } } },
|
||||
],
|
||||
}
|
||||
: {};
|
||||
|
||||
const visibilityFilter: Prisma.EnvelopeWhereInput = {
|
||||
visibility: {
|
||||
in: TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole],
|
||||
},
|
||||
};
|
||||
|
||||
const teamEmailFilters: Prisma.EnvelopeWhereInput[] = [];
|
||||
|
||||
if (team.teamEmail) {
|
||||
teamEmailFilters.push(
|
||||
{
|
||||
user: {
|
||||
email: team.teamEmail.email,
|
||||
},
|
||||
},
|
||||
{
|
||||
recipients: {
|
||||
some: {
|
||||
email: team.teamEmail.email,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const whereClause: Prisma.EnvelopeWhereInput = {
|
||||
AND: [
|
||||
searchFilter,
|
||||
{
|
||||
OR: [
|
||||
{
|
||||
teamId: team.id,
|
||||
...visibilityFilter,
|
||||
},
|
||||
{
|
||||
userId,
|
||||
},
|
||||
...teamEmailFilters,
|
||||
],
|
||||
},
|
||||
{
|
||||
deletedAt: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
if (type) {
|
||||
whereClause.type = type;
|
||||
}
|
||||
|
||||
if (templateId) {
|
||||
whereClause.templateId = templateId;
|
||||
}
|
||||
|
||||
if (source) {
|
||||
whereClause.source = source;
|
||||
}
|
||||
|
||||
if (status) {
|
||||
whereClause.status = status;
|
||||
}
|
||||
|
||||
if (folderId !== undefined) {
|
||||
whereClause.folderId = folderId;
|
||||
} else {
|
||||
whereClause.folderId = null;
|
||||
}
|
||||
|
||||
const [data, count] = await Promise.all([
|
||||
prisma.envelope.findMany({
|
||||
where: whereClause,
|
||||
skip: Math.max(page - 1, 0) * perPage,
|
||||
take: perPage,
|
||||
orderBy: {
|
||||
[orderByColumn]: orderByDirection,
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
recipients: {
|
||||
orderBy: {
|
||||
id: 'asc',
|
||||
},
|
||||
},
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
url: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.envelope.count({
|
||||
where: whereClause,
|
||||
}),
|
||||
]);
|
||||
|
||||
const maskedData = data.map((envelope) =>
|
||||
maskRecipientTokensForDocument({
|
||||
document: envelope,
|
||||
user,
|
||||
}),
|
||||
);
|
||||
|
||||
const mappedData = maskedData.map((envelope) => ({
|
||||
...envelope,
|
||||
recipients: envelope.Recipient,
|
||||
user: {
|
||||
id: envelope.user.id,
|
||||
name: envelope.user.name || '',
|
||||
email: envelope.user.email,
|
||||
},
|
||||
}));
|
||||
|
||||
return {
|
||||
data: mappedData,
|
||||
count,
|
||||
currentPage: Math.max(page, 1),
|
||||
perPage,
|
||||
totalPages: Math.ceil(count / perPage),
|
||||
} satisfies FindResultResponse<typeof mappedData>;
|
||||
};
|
||||
@@ -1,6 +1,3 @@
|
||||
/**
|
||||
* @deprecated We use Konva to generate the audit logs PDF now.
|
||||
*/
|
||||
import { DateTime } from 'luxon';
|
||||
import type { Browser } from 'playwright';
|
||||
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
/**
|
||||
* @deprecated We use Konva to generate the certificate PDF now..
|
||||
*/
|
||||
import { DateTime } from 'luxon';
|
||||
import type { Browser } from 'playwright';
|
||||
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}`;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -745,5 +745,3 @@ export type DocumentAuditLogByType<T = TDocumentAuditLog['type']> = Extract<
|
||||
TDocumentAuditLog,
|
||||
{ type: T }
|
||||
>;
|
||||
|
||||
export type TDocumentAuditLogBaseSchema = z.infer<typeof ZDocumentAuditLogBaseSchema>;
|
||||
|
||||
@@ -115,5 +115,40 @@ export type TEnvelopeLite = z.infer<typeof ZEnvelopeLiteSchema>;
|
||||
/**
|
||||
* A version of the envelope response schema when returning multiple envelopes at once from a single API endpoint.
|
||||
*/
|
||||
// export const ZEnvelopeManySchema = X
|
||||
// export type TEnvelopeMany = z.infer<typeof ZEnvelopeManySchema>;
|
||||
export const ZEnvelopeManySchema = EnvelopeSchema.pick({
|
||||
internalVersion: true,
|
||||
type: true,
|
||||
status: true,
|
||||
source: true,
|
||||
visibility: true,
|
||||
templateType: true,
|
||||
id: true,
|
||||
secondaryId: true,
|
||||
externalId: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
completedAt: true,
|
||||
deletedAt: true,
|
||||
title: true,
|
||||
authOptions: true,
|
||||
formValues: true,
|
||||
publicTitle: true,
|
||||
publicDescription: true,
|
||||
userId: true,
|
||||
teamId: true,
|
||||
folderId: true,
|
||||
templateId: true,
|
||||
}).extend({
|
||||
user: z.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
email: z.string(),
|
||||
}),
|
||||
recipients: ZEnvelopeRecipientLiteSchema.array(),
|
||||
team: TeamSchema.pick({
|
||||
id: true,
|
||||
url: true,
|
||||
}).nullable(),
|
||||
});
|
||||
|
||||
export type TEnvelopeMany = z.infer<typeof ZEnvelopeManySchema>;
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Envelope_type_idx" ON "Envelope"("type");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Envelope_status_idx" ON "Envelope"("status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Envelope_createdAt_idx" ON "Envelope"("createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Organisation_name_idx" ON "Organisation"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Organisation_ownerUserId_idx" ON "Organisation"("ownerUserId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "OrganisationMember_organisationId_idx" ON "OrganisationMember"("organisationId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Recipient_email_idx" ON "Recipient"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Recipient_signedAt_idx" ON "Recipient"("signedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Team_name_idx" ON "Team"("name");
|
||||
@@ -430,12 +430,9 @@ model Envelope {
|
||||
|
||||
envelopeAttachments EnvelopeAttachment[]
|
||||
|
||||
@@index([type])
|
||||
@@index([status])
|
||||
@@index([userId])
|
||||
@@index([teamId])
|
||||
@@index([folderId])
|
||||
@@index([createdAt])
|
||||
@@index([teamId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model EnvelopeItem {
|
||||
@@ -586,10 +583,8 @@ model Recipient {
|
||||
fields Field[]
|
||||
signatures Signature[]
|
||||
|
||||
@@index([token])
|
||||
@@index([email])
|
||||
@@index([envelopeId])
|
||||
@@index([signedAt])
|
||||
@@index([token])
|
||||
}
|
||||
|
||||
enum FieldType {
|
||||
@@ -699,9 +694,6 @@ model Organisation {
|
||||
|
||||
organisationAuthenticationPortalId String @unique
|
||||
organisationAuthenticationPortal OrganisationAuthenticationPortal @relation(fields: [organisationAuthenticationPortalId], references: [id])
|
||||
|
||||
@@index([name])
|
||||
@@index([ownerUserId])
|
||||
}
|
||||
|
||||
model OrganisationMember {
|
||||
@@ -718,7 +710,6 @@ model OrganisationMember {
|
||||
organisationGroupMembers OrganisationGroupMember[]
|
||||
|
||||
@@unique([userId, organisationId])
|
||||
@@index([organisationId])
|
||||
}
|
||||
|
||||
model OrganisationMemberInvite {
|
||||
@@ -892,7 +883,6 @@ model Team {
|
||||
teamGlobalSettingsId String @unique
|
||||
teamGlobalSettings TeamGlobalSettings @relation(fields: [teamGlobalSettingsId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([name])
|
||||
@@index([organisationId])
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
56
packages/trpc/server/envelope-router/find-envelopes.ts
Normal file
56
packages/trpc/server/envelope-router/find-envelopes.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { findEnvelopes } from '@documenso/lib/server-only/envelope/find-envelopes';
|
||||
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
import {
|
||||
ZFindEnvelopesRequestSchema,
|
||||
ZFindEnvelopesResponseSchema,
|
||||
findEnvelopesMeta,
|
||||
} from './find-envelopes.types';
|
||||
|
||||
export const findEnvelopesRoute = authenticatedProcedure
|
||||
.meta(findEnvelopesMeta)
|
||||
.input(ZFindEnvelopesRequestSchema)
|
||||
.output(ZFindEnvelopesResponseSchema)
|
||||
.query(async ({ input, ctx }) => {
|
||||
const { user, teamId } = ctx;
|
||||
|
||||
const {
|
||||
query,
|
||||
type,
|
||||
templateId,
|
||||
page,
|
||||
perPage,
|
||||
orderByDirection,
|
||||
orderByColumn,
|
||||
source,
|
||||
status,
|
||||
folderId,
|
||||
} = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
query,
|
||||
type,
|
||||
templateId,
|
||||
source,
|
||||
status,
|
||||
folderId,
|
||||
page,
|
||||
perPage,
|
||||
},
|
||||
});
|
||||
|
||||
return await findEnvelopes({
|
||||
userId: user.id,
|
||||
teamId,
|
||||
type,
|
||||
templateId,
|
||||
query,
|
||||
source,
|
||||
status,
|
||||
page,
|
||||
perPage,
|
||||
folderId,
|
||||
orderBy: orderByColumn ? { column: orderByColumn, direction: orderByDirection } : undefined,
|
||||
});
|
||||
});
|
||||
46
packages/trpc/server/envelope-router/find-envelopes.types.ts
Normal file
46
packages/trpc/server/envelope-router/find-envelopes.types.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { DocumentSource, DocumentStatus, EnvelopeType } from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZEnvelopeManySchema } from '@documenso/lib/types/envelope';
|
||||
import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||
|
||||
import type { TrpcRouteMeta } from '../trpc';
|
||||
|
||||
export const findEnvelopesMeta: TrpcRouteMeta = {
|
||||
openapi: {
|
||||
method: 'GET',
|
||||
path: '/envelope',
|
||||
summary: 'Find envelopes',
|
||||
description: 'Find envelopes based on search criteria',
|
||||
tags: ['Envelope'],
|
||||
},
|
||||
};
|
||||
|
||||
export const ZFindEnvelopesRequestSchema = ZFindSearchParamsSchema.extend({
|
||||
type: z
|
||||
.nativeEnum(EnvelopeType)
|
||||
.describe('Filter envelopes by type (DOCUMENT or TEMPLATE).')
|
||||
.optional(),
|
||||
templateId: z
|
||||
.number()
|
||||
.describe('Filter envelopes by the template ID used to create it.')
|
||||
.optional(),
|
||||
source: z
|
||||
.nativeEnum(DocumentSource)
|
||||
.describe('Filter envelopes by how it was created.')
|
||||
.optional(),
|
||||
status: z
|
||||
.nativeEnum(DocumentStatus)
|
||||
.describe('Filter envelopes by the current status.')
|
||||
.optional(),
|
||||
folderId: z.string().describe('Filter envelopes by folder ID.').optional(),
|
||||
orderByColumn: z.enum(['createdAt']).optional(),
|
||||
orderByDirection: z.enum(['asc', 'desc']).describe('Sort direction.').default('desc'),
|
||||
});
|
||||
|
||||
export const ZFindEnvelopesResponseSchema = ZFindResultResponse.extend({
|
||||
data: ZEnvelopeManySchema.array(),
|
||||
});
|
||||
|
||||
export type TFindEnvelopesRequest = z.infer<typeof ZFindEnvelopesRequestSchema>;
|
||||
export type TFindEnvelopesResponse = z.infer<typeof ZFindEnvelopesResponseSchema>;
|
||||
@@ -18,6 +18,7 @@ import { createEnvelopeRecipientsRoute } from './envelope-recipients/create-enve
|
||||
import { deleteEnvelopeRecipientRoute } from './envelope-recipients/delete-envelope-recipient';
|
||||
import { getEnvelopeRecipientRoute } from './envelope-recipients/get-envelope-recipient';
|
||||
import { updateEnvelopeRecipientsRoute } from './envelope-recipients/update-envelope-recipients';
|
||||
import { findEnvelopesRoute } from './find-envelopes';
|
||||
import { getEnvelopeRoute } from './get-envelope';
|
||||
import { getEnvelopeItemsRoute } from './get-envelope-items';
|
||||
import { getEnvelopeItemsByTokenRoute } from './get-envelope-items-by-token';
|
||||
@@ -65,6 +66,7 @@ export const envelopeRouter = router({
|
||||
set: setEnvelopeFieldsRoute,
|
||||
sign: signEnvelopeFieldRoute,
|
||||
},
|
||||
find: findEnvelopesRoute,
|
||||
get: getEnvelopeRoute,
|
||||
create: createEnvelopeRoute,
|
||||
use: useEnvelopeRoute,
|
||||
|
||||
Reference in New Issue
Block a user