mirror of
https://github.com/documenso/documenso.git
synced 2025-11-15 01:01:49 +10:00
Compare commits
10 Commits
feat/auto-
...
0977c16e33
| Author | SHA1 | Date | |
|---|---|---|---|
| 0977c16e33 | |||
| 88d5a636c3 | |||
| 1e6292b1d9 | |||
| d65866156d | |||
| fe8915162f | |||
| 37a2634aca | |||
| eff7d90f43 | |||
| db5524f8ce | |||
| 3d539b20ad | |||
| 48626b9169 |
@ -7,6 +7,7 @@ import { DateTime } from 'luxon';
|
|||||||
|
|
||||||
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
||||||
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
||||||
|
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
||||||
|
|
||||||
export type DocumentPageViewInformationProps = {
|
export type DocumentPageViewInformationProps = {
|
||||||
userId: number;
|
userId: number;
|
||||||
@ -40,6 +41,10 @@ export const DocumentPageViewInformation = ({
|
|||||||
.setLocale(i18n.locales?.[0] || i18n.locale)
|
.setLocale(i18n.locales?.[0] || i18n.locale)
|
||||||
.toRelative(),
|
.toRelative(),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
description: msg`Document ID (Legacy)`,
|
||||||
|
value: mapSecondaryIdToDocumentId(envelope.secondaryId),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [isMounted, envelope, userId]);
|
}, [isMounted, envelope, userId]);
|
||||||
|
|||||||
@ -7,11 +7,13 @@ import type { User } from '@prisma/client';
|
|||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
||||||
|
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
|
||||||
|
|
||||||
export type TemplatePageViewInformationProps = {
|
export type TemplatePageViewInformationProps = {
|
||||||
userId: number;
|
userId: number;
|
||||||
template: {
|
template: {
|
||||||
userId: number;
|
userId: number;
|
||||||
|
secondaryId: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
user: Pick<User, 'id' | 'name' | 'email'>;
|
user: Pick<User, 'id' | 'name' | 'email'>;
|
||||||
@ -43,6 +45,10 @@ export const TemplatePageViewInformation = ({
|
|||||||
.setLocale(i18n.locales?.[0] || i18n.locale)
|
.setLocale(i18n.locales?.[0] || i18n.locale)
|
||||||
.toRelative(),
|
.toRelative(),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
description: msg`Template ID (Legacy)`,
|
||||||
|
value: mapSecondaryIdToTemplateId(template.secondaryId),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [isMounted, template, userId]);
|
}, [isMounted, template, userId]);
|
||||||
|
|||||||
@ -41,6 +41,7 @@
|
|||||||
"@simplewebauthn/server": "^9.0.3",
|
"@simplewebauthn/server": "^9.0.3",
|
||||||
"autoprefixer": "^10.4.13",
|
"autoprefixer": "^10.4.13",
|
||||||
"colord": "^2.9.3",
|
"colord": "^2.9.3",
|
||||||
|
"content-disposition": "^0.5.4",
|
||||||
"framer-motion": "^10.12.8",
|
"framer-motion": "^10.12.8",
|
||||||
"hono": "4.7.0",
|
"hono": "4.7.0",
|
||||||
"hono-rate-limiter": "^0.4.2",
|
"hono-rate-limiter": "^0.4.2",
|
||||||
@ -87,6 +88,7 @@
|
|||||||
"@rollup/plugin-node-resolve": "^16.0.0",
|
"@rollup/plugin-node-resolve": "^16.0.0",
|
||||||
"@rollup/plugin-typescript": "^12.1.2",
|
"@rollup/plugin-typescript": "^12.1.2",
|
||||||
"@simplewebauthn/types": "^9.0.1",
|
"@simplewebauthn/types": "^9.0.1",
|
||||||
|
"@types/content-disposition": "^0.5.9",
|
||||||
"@types/formidable": "^2.0.6",
|
"@types/formidable": "^2.0.6",
|
||||||
"@types/luxon": "^3.3.1",
|
"@types/luxon": "^3.3.1",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
@ -104,5 +106,5 @@
|
|||||||
"vite-plugin-babel-macros": "^1.0.6",
|
"vite-plugin-babel-macros": "^1.0.6",
|
||||||
"vite-tsconfig-paths": "^5.1.4"
|
"vite-tsconfig-paths": "^5.1.4"
|
||||||
},
|
},
|
||||||
"version": "2.0.0"
|
"version": "2.0.5"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { type DocumentDataType, DocumentStatus } from '@prisma/client';
|
import { type DocumentDataType, DocumentStatus } from '@prisma/client';
|
||||||
|
import contentDisposition from 'content-disposition';
|
||||||
import { type Context } from 'hono';
|
import { type Context } from 'hono';
|
||||||
|
|
||||||
import { sha256 } from '@documenso/lib/universal/crypto';
|
import { sha256 } from '@documenso/lib/universal/crypto';
|
||||||
@ -34,7 +35,7 @@ export const handleEnvelopeItemFileRequest = async ({
|
|||||||
|
|
||||||
const etag = Buffer.from(sha256(documentDataToUse)).toString('hex');
|
const etag = Buffer.from(sha256(documentDataToUse)).toString('hex');
|
||||||
|
|
||||||
if (c.req.header('If-None-Match') === etag) {
|
if (c.req.header('If-None-Match') === etag && !isDownload) {
|
||||||
return c.body(null, 304);
|
return c.body(null, 304);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -58,7 +59,6 @@ export const handleEnvelopeItemFileRequest = async ({
|
|||||||
if (status === DocumentStatus.COMPLETED) {
|
if (status === DocumentStatus.COMPLETED) {
|
||||||
c.header('Cache-Control', 'public, max-age=31536000, immutable');
|
c.header('Cache-Control', 'public, max-age=31536000, immutable');
|
||||||
} else {
|
} else {
|
||||||
// Set a tiny 1 minute cache, with must-revalidate to ensure the client always checks for updates.
|
|
||||||
c.header('Cache-Control', 'public, max-age=0, must-revalidate');
|
c.header('Cache-Control', 'public, max-age=0, must-revalidate');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -69,7 +69,7 @@ export const handleEnvelopeItemFileRequest = async ({
|
|||||||
const suffix = version === 'signed' ? '_signed.pdf' : '.pdf';
|
const suffix = version === 'signed' ? '_signed.pdf' : '.pdf';
|
||||||
const filename = `${baseTitle}${suffix}`;
|
const filename = `${baseTitle}${suffix}`;
|
||||||
|
|
||||||
c.header('Content-Disposition', `attachment; filename="${filename}"`);
|
c.header('Content-Disposition', contentDisposition(filename));
|
||||||
|
|
||||||
// For downloads, prevent caching to ensure fresh data
|
// For downloads, prevent caching to ensure fresh data
|
||||||
c.header('Cache-Control', 'no-cache, no-store, must-revalidate');
|
c.header('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||||
|
|||||||
28
package-lock.json
generated
28
package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@documenso/root",
|
"name": "@documenso/root",
|
||||||
"version": "2.0.0",
|
"version": "2.0.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@documenso/root",
|
"name": "@documenso/root",
|
||||||
"version": "2.0.0",
|
"version": "2.0.5",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"apps/*",
|
"apps/*",
|
||||||
"packages/*"
|
"packages/*"
|
||||||
@ -19,7 +19,6 @@
|
|||||||
"inngest-cli": "^0.29.1",
|
"inngest-cli": "^0.29.1",
|
||||||
"luxon": "^3.5.0",
|
"luxon": "^3.5.0",
|
||||||
"mupdf": "^1.0.0",
|
"mupdf": "^1.0.0",
|
||||||
"pdf2json": "^4.0.0",
|
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"typescript": "5.6.2",
|
"typescript": "5.6.2",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
@ -101,7 +100,7 @@
|
|||||||
},
|
},
|
||||||
"apps/remix": {
|
"apps/remix": {
|
||||||
"name": "@documenso/remix",
|
"name": "@documenso/remix",
|
||||||
"version": "2.0.0",
|
"version": "2.0.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cantoo/pdf-lib": "^2.5.2",
|
"@cantoo/pdf-lib": "^2.5.2",
|
||||||
"@documenso/api": "*",
|
"@documenso/api": "*",
|
||||||
@ -130,6 +129,7 @@
|
|||||||
"@simplewebauthn/server": "^9.0.3",
|
"@simplewebauthn/server": "^9.0.3",
|
||||||
"autoprefixer": "^10.4.13",
|
"autoprefixer": "^10.4.13",
|
||||||
"colord": "^2.9.3",
|
"colord": "^2.9.3",
|
||||||
|
"content-disposition": "^0.5.4",
|
||||||
"framer-motion": "^10.12.8",
|
"framer-motion": "^10.12.8",
|
||||||
"hono": "4.7.0",
|
"hono": "4.7.0",
|
||||||
"hono-rate-limiter": "^0.4.2",
|
"hono-rate-limiter": "^0.4.2",
|
||||||
@ -176,6 +176,7 @@
|
|||||||
"@rollup/plugin-node-resolve": "^16.0.0",
|
"@rollup/plugin-node-resolve": "^16.0.0",
|
||||||
"@rollup/plugin-typescript": "^12.1.2",
|
"@rollup/plugin-typescript": "^12.1.2",
|
||||||
"@simplewebauthn/types": "^9.0.1",
|
"@simplewebauthn/types": "^9.0.1",
|
||||||
|
"@types/content-disposition": "^0.5.9",
|
||||||
"@types/formidable": "^2.0.6",
|
"@types/formidable": "^2.0.6",
|
||||||
"@types/luxon": "^3.3.1",
|
"@types/luxon": "^3.3.1",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
@ -12316,6 +12317,13 @@
|
|||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/content-disposition": {
|
||||||
|
"version": "0.5.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/content-disposition/-/content-disposition-0.5.9.tgz",
|
||||||
|
"integrity": "sha512-8uYXI3Gw35MhiVYhG3s295oihrxRyytcRHjSjqnqZVDDy/xcGBRny7+Xj1Wgfhv5QzRtN2hB2dVRBUX9XW3UcQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/cross-spawn": {
|
"node_modules/@types/cross-spawn": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/cross-spawn/-/cross-spawn-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/cross-spawn/-/cross-spawn-6.0.2.tgz",
|
||||||
@ -27489,18 +27497,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/pdf2json": {
|
|
||||||
"version": "4.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/pdf2json/-/pdf2json-4.0.0.tgz",
|
|
||||||
"integrity": "sha512-WkezNsLK8sGpuFC7+PPP0DsXROwdoOxmXPBTtUWWkCwCi/Vi97MRC52Ly6FWIJjOKIywpm/L2oaUgSrmtU+7ZQ==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"bin": {
|
|
||||||
"pdf2json": "bin/pdf2json.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.18.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/pdfjs-dist": {
|
"node_modules/pdfjs-dist": {
|
||||||
"version": "3.11.174",
|
"version": "3.11.174",
|
||||||
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-3.11.174.tgz",
|
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-3.11.174.tgz",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.0.0",
|
"version": "2.0.5",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "turbo run build",
|
"build": "turbo run build",
|
||||||
"dev": "turbo run dev --filter=@documenso/remix",
|
"dev": "turbo run dev --filter=@documenso/remix",
|
||||||
@ -85,7 +85,6 @@
|
|||||||
"inngest-cli": "^0.29.1",
|
"inngest-cli": "^0.29.1",
|
||||||
"luxon": "^3.5.0",
|
"luxon": "^3.5.0",
|
||||||
"mupdf": "^1.0.0",
|
"mupdf": "^1.0.0",
|
||||||
"pdf2json": "^4.0.0",
|
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"typescript": "5.6.2",
|
"typescript": "5.6.2",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
|
|||||||
@ -1,129 +0,0 @@
|
|||||||
import { type Page, expect, test } from '@playwright/test';
|
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
import { seedUser } from '@documenso/prisma/seed/users';
|
|
||||||
|
|
||||||
import { apiSignin } from '../fixtures/authentication';
|
|
||||||
|
|
||||||
const SINGLE_PLACEHOLDER_PDF_PATH = path.join(
|
|
||||||
__dirname,
|
|
||||||
'../../../assets/project-proposal-single-recipient.pdf',
|
|
||||||
);
|
|
||||||
|
|
||||||
const MULTIPLE_PLACEHOLDER_PDF_PATH = path.join(
|
|
||||||
__dirname,
|
|
||||||
'../../../assets/project-proposal-multiple-fields-and-recipients.pdf',
|
|
||||||
);
|
|
||||||
|
|
||||||
const setupUserAndSignIn = async (page: Page) => {
|
|
||||||
const { user, team } = await seedUser();
|
|
||||||
|
|
||||||
await apiSignin({
|
|
||||||
page,
|
|
||||||
email: user.email,
|
|
||||||
redirectPath: `/t/${team.url}/documents`,
|
|
||||||
});
|
|
||||||
|
|
||||||
return { user, team };
|
|
||||||
};
|
|
||||||
|
|
||||||
const uploadPdfAndContinue = async (page: Page, pdfPath: string, continueClicks: number = 1) => {
|
|
||||||
const fileInput = page.locator('input[type="file"]').nth(1);
|
|
||||||
await fileInput.waitFor({ state: 'attached' });
|
|
||||||
await fileInput.setInputFiles(pdfPath);
|
|
||||||
|
|
||||||
await page.waitForTimeout(3000);
|
|
||||||
|
|
||||||
for (let i = 0; i < continueClicks; i++) {
|
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
test.describe('PDF Placeholders with single recipient', () => {
|
|
||||||
test('[AUTO_PLACING_FIELDS]: should automatically create recipients from PDF placeholders', async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
await setupUserAndSignIn(page);
|
|
||||||
await uploadPdfAndContinue(page, SINGLE_PLACEHOLDER_PDF_PATH, 1);
|
|
||||||
|
|
||||||
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
|
||||||
await expect(page.getByPlaceholder('Email')).toHaveValue('recipient.1@documenso.com');
|
|
||||||
await expect(page.getByPlaceholder('Name')).toHaveValue('Recipient 1');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('[AUTO_PLACING_FIELDS]: should automatically place fields from PDF placeholders', async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
await setupUserAndSignIn(page);
|
|
||||||
await uploadPdfAndContinue(page, SINGLE_PLACEHOLDER_PDF_PATH, 2);
|
|
||||||
|
|
||||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
|
||||||
|
|
||||||
await expect(page.locator('[data-field-type="SIGNATURE"]')).toBeVisible();
|
|
||||||
await expect(page.locator('[data-field-type="EMAIL"]')).toBeVisible();
|
|
||||||
await expect(page.locator('[data-field-type="NAME"]')).toBeVisible();
|
|
||||||
await expect(page.locator('[data-field-type="TEXT"]')).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('[AUTO_PLACING_FIELDS]: should automatically configure fields from PDF placeholders', async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
await setupUserAndSignIn(page);
|
|
||||||
await uploadPdfAndContinue(page, SINGLE_PLACEHOLDER_PDF_PATH, 2);
|
|
||||||
|
|
||||||
await page.getByText('Text').nth(1).click();
|
|
||||||
await page.getByRole('button', { name: 'Advanced settings' }).click();
|
|
||||||
|
|
||||||
await expect(page.getByRole('heading', { name: 'Advanced settings' })).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
page
|
|
||||||
.locator('div')
|
|
||||||
.filter({ hasText: /^Required field$/ })
|
|
||||||
.getByRole('switch'),
|
|
||||||
).toBeChecked();
|
|
||||||
|
|
||||||
await expect(page.getByRole('combobox')).toHaveText('Right');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('PDF Placeholders with multiple recipients', () => {
|
|
||||||
test('[AUTO_PLACING_FIELDS]: should automatically create recipients from PDF placeholders', async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
await setupUserAndSignIn(page);
|
|
||||||
await uploadPdfAndContinue(page, MULTIPLE_PLACEHOLDER_PDF_PATH, 1);
|
|
||||||
|
|
||||||
await expect(page.getByTestId('signer-email-input').first()).toHaveValue(
|
|
||||||
'recipient.1@documenso.com',
|
|
||||||
);
|
|
||||||
await expect(page.getByLabel('Name').first()).toHaveValue('Recipient 1');
|
|
||||||
|
|
||||||
await expect(page.getByTestId('signer-email-input').nth(1)).toHaveValue(
|
|
||||||
'recipient.2@documenso.com',
|
|
||||||
);
|
|
||||||
await expect(page.getByLabel('Name').nth(1)).toHaveValue('Recipient 2');
|
|
||||||
|
|
||||||
await expect(page.getByTestId('signer-email-input').nth(2)).toHaveValue(
|
|
||||||
'recipient.3@documenso.com',
|
|
||||||
);
|
|
||||||
await expect(page.getByLabel('Name').nth(2)).toHaveValue('Recipient 3');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('[AUTO_PLACING_FIELDS]: should automatically create fields from PDF placeholders', async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
await setupUserAndSignIn(page);
|
|
||||||
await uploadPdfAndContinue(page, MULTIPLE_PLACEHOLDER_PDF_PATH, 2);
|
|
||||||
|
|
||||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
|
||||||
|
|
||||||
await expect(page.locator('[data-field-type="SIGNATURE"]').first()).toBeVisible();
|
|
||||||
await expect(page.locator('[data-field-type="SIGNATURE"]').nth(1)).toBeVisible();
|
|
||||||
await expect(page.locator('[data-field-type="SIGNATURE"]').nth(2)).toBeVisible();
|
|
||||||
await expect(page.locator('[data-field-type="EMAIL"]').first()).toBeVisible();
|
|
||||||
await expect(page.locator('[data-field-type="EMAIL"]').nth(1)).toBeVisible();
|
|
||||||
await expect(page.locator('[data-field-type="NAME"]')).toBeVisible();
|
|
||||||
await expect(page.locator('[data-field-type="TEXT"]')).toBeVisible();
|
|
||||||
await expect(page.locator('[data-field-type="NUMBER"]')).toBeVisible();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Binary file not shown.
Binary file not shown.
@ -32,6 +32,7 @@ export type JobDefinition<Name extends string = string, Schema = any> = {
|
|||||||
name: string;
|
name: string;
|
||||||
version: string;
|
version: string;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
optimizeParallelism?: boolean;
|
||||||
trigger: {
|
trigger: {
|
||||||
name: Name;
|
name: Name;
|
||||||
schema?: z.ZodType<Schema>;
|
schema?: z.ZodType<Schema>;
|
||||||
|
|||||||
@ -40,6 +40,7 @@ export class InngestJobProvider extends BaseJobProvider {
|
|||||||
{
|
{
|
||||||
id: job.id,
|
id: job.id,
|
||||||
name: job.name,
|
name: job.name,
|
||||||
|
optimizeParallelism: job.optimizeParallelism ?? false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
event: job.trigger.name,
|
event: job.trigger.name,
|
||||||
|
|||||||
@ -189,9 +189,44 @@ export const run = async ({
|
|||||||
settings,
|
settings,
|
||||||
});
|
});
|
||||||
|
|
||||||
const newDocumentData = await Promise.all(
|
// !: The commented out code is our desired implementation but we're seemingly
|
||||||
envelopeItems.map(async (envelopeItem) =>
|
// !: running into issues with inngest parallelism in production.
|
||||||
io.runTask(`decorate-and-sign-envelope-item-${envelopeItem.id}`, async () => {
|
// !: Until this is resolved we will do this sequentially which is slower but
|
||||||
|
// !: will actually work.
|
||||||
|
// const decoratePromises: Array<Promise<{ oldDocumentDataId: string; newDocumentDataId: string }>> =
|
||||||
|
// [];
|
||||||
|
|
||||||
|
// for (const envelopeItem of envelopeItems) {
|
||||||
|
// const task = io.runTask(`decorate-${envelopeItem.id}`, async () => {
|
||||||
|
// const envelopeItemFields = envelope.envelopeItems.find(
|
||||||
|
// (item) => item.id === envelopeItem.id,
|
||||||
|
// )?.field;
|
||||||
|
|
||||||
|
// if (!envelopeItemFields) {
|
||||||
|
// throw new Error(`Envelope item fields not found for envelope item ${envelopeItem.id}`);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// return decorateAndSignPdf({
|
||||||
|
// envelope,
|
||||||
|
// envelopeItem,
|
||||||
|
// envelopeItemFields,
|
||||||
|
// isRejected,
|
||||||
|
// rejectionReason,
|
||||||
|
// certificateData,
|
||||||
|
// auditLogData,
|
||||||
|
// });
|
||||||
|
// });
|
||||||
|
|
||||||
|
// decoratePromises.push(task);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const newDocumentData = await Promise.all(decoratePromises);
|
||||||
|
|
||||||
|
// TODO: Remove once parallelization is working
|
||||||
|
const newDocumentData: Array<{ oldDocumentDataId: string; newDocumentDataId: string }> = [];
|
||||||
|
|
||||||
|
for (const envelopeItem of envelopeItems) {
|
||||||
|
const result = await io.runTask(`decorate-${envelopeItem.id}`, async () => {
|
||||||
const envelopeItemFields = envelope.envelopeItems.find(
|
const envelopeItemFields = envelope.envelopeItems.find(
|
||||||
(item) => item.id === envelopeItem.id,
|
(item) => item.id === envelopeItem.id,
|
||||||
)?.field;
|
)?.field;
|
||||||
@ -209,9 +244,10 @@ export const run = async ({
|
|||||||
certificateData,
|
certificateData,
|
||||||
auditLogData,
|
auditLogData,
|
||||||
});
|
});
|
||||||
}),
|
});
|
||||||
),
|
|
||||||
);
|
newDocumentData.push(result);
|
||||||
|
}
|
||||||
|
|
||||||
const postHog = PostHogServerClient();
|
const postHog = PostHogServerClient();
|
||||||
|
|
||||||
|
|||||||
@ -18,6 +18,7 @@ export const SEAL_DOCUMENT_JOB_DEFINITION = {
|
|||||||
id: SEAL_DOCUMENT_JOB_DEFINITION_ID,
|
id: SEAL_DOCUMENT_JOB_DEFINITION_ID,
|
||||||
name: 'Seal Document',
|
name: 'Seal Document',
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
|
optimizeParallelism: true,
|
||||||
trigger: {
|
trigger: {
|
||||||
name: SEAL_DOCUMENT_JOB_DEFINITION_ID,
|
name: SEAL_DOCUMENT_JOB_DEFINITION_ID,
|
||||||
schema: SEAL_DOCUMENT_JOB_DEFINITION_SCHEMA,
|
schema: SEAL_DOCUMENT_JOB_DEFINITION_SCHEMA,
|
||||||
|
|||||||
@ -10,11 +10,6 @@ import {
|
|||||||
} from '@prisma/client';
|
} from '@prisma/client';
|
||||||
|
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import {
|
|
||||||
extractPlaceholdersFromPDF,
|
|
||||||
insertFieldsFromPlaceholdersInPDF,
|
|
||||||
removePlaceholdersFromPDF,
|
|
||||||
} from '@documenso/lib/server-only/pdf/auto-place-fields';
|
|
||||||
import { normalizePdf as makeNormalizedPdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
|
import { normalizePdf as makeNormalizedPdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
|
||||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||||
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||||
@ -39,7 +34,6 @@ import { getFileServerSide } from '../../universal/upload/get-file.server';
|
|||||||
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
|
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
|
||||||
import { extractDerivedDocumentMeta } from '../../utils/document';
|
import { extractDerivedDocumentMeta } from '../../utils/document';
|
||||||
import { createDocumentAuthOptions, createRecipientAuthOptions } from '../../utils/document-auth';
|
import { createDocumentAuthOptions, createRecipientAuthOptions } from '../../utils/document-auth';
|
||||||
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
|
||||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||||
import { incrementDocumentId, incrementTemplateId } from '../envelope/increment-id';
|
import { incrementDocumentId, incrementTemplateId } from '../envelope/increment-id';
|
||||||
import { getTeamSettings } from '../team/get-team-settings';
|
import { getTeamSettings } from '../team/get-team-settings';
|
||||||
@ -262,7 +256,7 @@ export const createEnvelope = async ({
|
|||||||
? await incrementDocumentId().then((v) => v.formattedDocumentId)
|
? await incrementDocumentId().then((v) => v.formattedDocumentId)
|
||||||
: await incrementTemplateId().then((v) => v.formattedTemplateId);
|
: await incrementTemplateId().then((v) => v.formattedTemplateId);
|
||||||
|
|
||||||
const createdEnvelope = await prisma.$transaction(async (tx) => {
|
return await prisma.$transaction(async (tx) => {
|
||||||
const envelope = await tx.envelope.create({
|
const envelope = await tx.envelope.create({
|
||||||
data: {
|
data: {
|
||||||
id: prefixedId('envelope'),
|
id: prefixedId('envelope'),
|
||||||
@ -382,12 +376,8 @@ export const createEnvelope = async ({
|
|||||||
recipients: true,
|
recipients: true,
|
||||||
fields: true,
|
fields: true,
|
||||||
folder: true,
|
folder: true,
|
||||||
|
envelopeItems: true,
|
||||||
envelopeAttachments: true,
|
envelopeAttachments: true,
|
||||||
envelopeItems: {
|
|
||||||
include: {
|
|
||||||
documentData: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -423,74 +413,4 @@ export const createEnvelope = async ({
|
|||||||
|
|
||||||
return createdEnvelope;
|
return createdEnvelope;
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const envelopeItem of createdEnvelope.envelopeItems) {
|
|
||||||
const buffer = await getFileServerSide(envelopeItem.documentData);
|
|
||||||
const pdfToProcess = Buffer.from(buffer);
|
|
||||||
|
|
||||||
const envelopeOptions: EnvelopeIdOptions = {
|
|
||||||
type: 'envelopeId',
|
|
||||||
id: createdEnvelope.id,
|
|
||||||
};
|
|
||||||
|
|
||||||
const placeholders = await extractPlaceholdersFromPDF(pdfToProcess);
|
|
||||||
|
|
||||||
if (placeholders.length > 0) {
|
|
||||||
const pdfWithoutPlaceholders = await removePlaceholdersFromPDF(pdfToProcess);
|
|
||||||
|
|
||||||
await insertFieldsFromPlaceholdersInPDF(
|
|
||||||
pdfWithoutPlaceholders,
|
|
||||||
userId,
|
|
||||||
teamId,
|
|
||||||
envelopeOptions,
|
|
||||||
requestMetadata,
|
|
||||||
envelopeItem.id,
|
|
||||||
createdEnvelope.recipients,
|
|
||||||
);
|
|
||||||
|
|
||||||
const titleToUse = envelopeItem.title || title;
|
|
||||||
const fileName = titleToUse.endsWith('.pdf') ? titleToUse : `${titleToUse}.pdf`;
|
|
||||||
|
|
||||||
const newDocumentData = await putPdfFileServerSide({
|
|
||||||
name: fileName,
|
|
||||||
type: 'application/pdf',
|
|
||||||
arrayBuffer: async () => Promise.resolve(pdfWithoutPlaceholders),
|
|
||||||
});
|
|
||||||
|
|
||||||
await prisma.envelopeItem.update({
|
|
||||||
where: {
|
|
||||||
id: envelopeItem.id,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
documentDataId: newDocumentData.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const finalEnvelope = await prisma.envelope.findFirst({
|
|
||||||
where: {
|
|
||||||
id: createdEnvelope.id,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
documentMeta: true,
|
|
||||||
recipients: true,
|
|
||||||
fields: true,
|
|
||||||
folder: true,
|
|
||||||
envelopeAttachments: true,
|
|
||||||
envelopeItems: {
|
|
||||||
include: {
|
|
||||||
documentData: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!finalEnvelope) {
|
|
||||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
|
||||||
message: 'Envelope not found',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return finalEnvelope;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,347 +0,0 @@
|
|||||||
import { PDFDocument, rgb } from '@cantoo/pdf-lib';
|
|
||||||
import type { Recipient } from '@prisma/client';
|
|
||||||
import PDFParser from 'pdf2json';
|
|
||||||
|
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
|
||||||
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
|
||||||
import { createEnvelopeFields } from '@documenso/lib/server-only/field/create-envelope-fields';
|
|
||||||
import { type TFieldAndMeta, ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
|
|
||||||
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
|
||||||
import type { EnvelopeIdOptions } from '@documenso/lib/utils/envelope';
|
|
||||||
import { prisma } from '@documenso/prisma';
|
|
||||||
|
|
||||||
import { getPageSize } from './get-page-size';
|
|
||||||
import {
|
|
||||||
determineRecipientsForPlaceholders,
|
|
||||||
extractRecipientPlaceholder,
|
|
||||||
findRecipientByPlaceholder,
|
|
||||||
parseFieldMetaFromPlaceholder,
|
|
||||||
parseFieldTypeFromPlaceholder,
|
|
||||||
} from './helpers';
|
|
||||||
|
|
||||||
const PLACEHOLDER_REGEX = /{{([^}]+)}}/g;
|
|
||||||
const DEFAULT_FIELD_HEIGHT_PERCENT = 2;
|
|
||||||
const WIDTH_ADJUSTMENT_FACTOR = 0.1;
|
|
||||||
const MIN_HEIGHT_THRESHOLD = 0.01;
|
|
||||||
|
|
||||||
type TextPosition = {
|
|
||||||
text: string;
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
w: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type CharIndexMapping = {
|
|
||||||
textPositionIndex: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type PlaceholderInfo = {
|
|
||||||
placeholder: string;
|
|
||||||
recipient: string;
|
|
||||||
fieldAndMeta: TFieldAndMeta;
|
|
||||||
page: number;
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
pageWidth: number;
|
|
||||||
pageHeight: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type FieldToCreate = TFieldAndMeta & {
|
|
||||||
envelopeItemId?: string;
|
|
||||||
recipientId: number;
|
|
||||||
page: number;
|
|
||||||
positionX: number;
|
|
||||||
positionY: number;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const extractPlaceholdersFromPDF = async (pdf: Buffer): Promise<PlaceholderInfo[]> => {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const parser = new PDFParser(null, true);
|
|
||||||
|
|
||||||
parser.on('pdfParser_dataError', (errData) => {
|
|
||||||
reject(errData);
|
|
||||||
});
|
|
||||||
|
|
||||||
parser.on('pdfParser_dataReady', (pdfData) => {
|
|
||||||
const placeholders: PlaceholderInfo[] = [];
|
|
||||||
|
|
||||||
pdfData.Pages.forEach((page, pageIndex) => {
|
|
||||||
/*
|
|
||||||
pdf2json returns the PDF page content as an array of characters.
|
|
||||||
We need to concatenate the characters to get the full text.
|
|
||||||
We also need to get the position of the text so we can place the placeholders in the correct position.
|
|
||||||
|
|
||||||
Page dimensions from PDF2JSON are in "page units" (relative coordinates)
|
|
||||||
*/
|
|
||||||
let pageText = '';
|
|
||||||
const textPositions: TextPosition[] = [];
|
|
||||||
const charIndexMappings: CharIndexMapping[] = [];
|
|
||||||
|
|
||||||
page.Texts.forEach((text) => {
|
|
||||||
/*
|
|
||||||
R is an array of objects containing each character, its position and styling information.
|
|
||||||
The decodedText stores the characters, without any other information.
|
|
||||||
|
|
||||||
textPositions stores each character and its position on the page.
|
|
||||||
*/
|
|
||||||
const decodedText = text.R.map((run) => decodeURIComponent(run.T)).join('');
|
|
||||||
|
|
||||||
/*
|
|
||||||
For each character in the decodedText, we store its position in the textPositions array.
|
|
||||||
This allows us to quickly find the position of a character in the textPositions array by its index.
|
|
||||||
*/
|
|
||||||
for (let i = 0; i < decodedText.length; i++) {
|
|
||||||
charIndexMappings.push({
|
|
||||||
textPositionIndex: textPositions.length,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
pageText += decodedText;
|
|
||||||
|
|
||||||
textPositions.push({
|
|
||||||
text: decodedText,
|
|
||||||
x: text.x,
|
|
||||||
y: text.y,
|
|
||||||
w: text.w || 0,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const placeholderMatches = pageText.matchAll(PLACEHOLDER_REGEX);
|
|
||||||
|
|
||||||
/*
|
|
||||||
A placeholder match has the following format:
|
|
||||||
|
|
||||||
[
|
|
||||||
'{{fieldType,recipient,fieldMeta}}',
|
|
||||||
'fieldType,recipient,fieldMeta',
|
|
||||||
'index: <number>',
|
|
||||||
'input: <pdf-text>'
|
|
||||||
]
|
|
||||||
*/
|
|
||||||
for (const placeholderMatch of placeholderMatches) {
|
|
||||||
const placeholder = placeholderMatch[0];
|
|
||||||
const placeholderData = placeholderMatch[1].split(',').map((property) => property.trim());
|
|
||||||
|
|
||||||
const [fieldTypeString, recipient, ...fieldMetaData] = placeholderData;
|
|
||||||
|
|
||||||
const rawFieldMeta = Object.fromEntries(
|
|
||||||
fieldMetaData.map((property) => property.split('=')),
|
|
||||||
);
|
|
||||||
|
|
||||||
const fieldType = parseFieldTypeFromPlaceholder(fieldTypeString);
|
|
||||||
const parsedFieldMeta = parseFieldMetaFromPlaceholder(rawFieldMeta, fieldType);
|
|
||||||
|
|
||||||
const fieldAndMeta: TFieldAndMeta = ZFieldAndMetaSchema.parse({
|
|
||||||
type: fieldType,
|
|
||||||
fieldMeta: parsedFieldMeta,
|
|
||||||
});
|
|
||||||
|
|
||||||
/*
|
|
||||||
Find the position of where the placeholder starts and ends in the text.
|
|
||||||
|
|
||||||
Then find the position of the characters in the textPositions array.
|
|
||||||
This allows us to quickly find the position of a character in the textPositions array by its index.
|
|
||||||
*/
|
|
||||||
const placeholderEndCharIndex = placeholderMatch.index + placeholder.length;
|
|
||||||
|
|
||||||
/*
|
|
||||||
Get the index of the placeholder's first and last character in the textPositions array.
|
|
||||||
Used to retrieve the character information from the textPositions array.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
startTextPosIndex - 1
|
|
||||||
endTextPosIndex - 40
|
|
||||||
*/
|
|
||||||
const startTextPosIndex = charIndexMappings[placeholderMatch.index].textPositionIndex;
|
|
||||||
const endTextPosIndex = charIndexMappings[placeholderEndCharIndex - 1].textPositionIndex;
|
|
||||||
|
|
||||||
/*
|
|
||||||
Get the placeholder's first and last character information from the textPositions array.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
placeholderStart = { text: '{', x: 100, y: 100, w: 100 }
|
|
||||||
placeholderEnd = { text: '}', x: 200, y: 100, w: 100 }
|
|
||||||
*/
|
|
||||||
const placeholderStart = textPositions[startTextPosIndex];
|
|
||||||
const placeholderEnd = textPositions[endTextPosIndex];
|
|
||||||
|
|
||||||
const width =
|
|
||||||
placeholderEnd.x + placeholderEnd.w * WIDTH_ADJUSTMENT_FACTOR - placeholderStart.x;
|
|
||||||
|
|
||||||
placeholders.push({
|
|
||||||
placeholder,
|
|
||||||
recipient,
|
|
||||||
fieldAndMeta,
|
|
||||||
page: pageIndex + 1,
|
|
||||||
x: placeholderStart.x,
|
|
||||||
y: placeholderStart.y,
|
|
||||||
width,
|
|
||||||
height: 1,
|
|
||||||
pageWidth: page.Width,
|
|
||||||
pageHeight: page.Height,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
resolve(placeholders);
|
|
||||||
});
|
|
||||||
|
|
||||||
parser.parseBuffer(pdf);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const removePlaceholdersFromPDF = async (pdf: Buffer): Promise<Buffer> => {
|
|
||||||
const placeholders = await extractPlaceholdersFromPDF(pdf);
|
|
||||||
|
|
||||||
const pdfDoc = await PDFDocument.load(new Uint8Array(pdf));
|
|
||||||
const pages = pdfDoc.getPages();
|
|
||||||
|
|
||||||
for (const placeholder of placeholders) {
|
|
||||||
const pageIndex = placeholder.page - 1;
|
|
||||||
const page = pages[pageIndex];
|
|
||||||
|
|
||||||
const { width: pdfLibPageWidth, height: pdfLibPageHeight } = getPageSize(page);
|
|
||||||
|
|
||||||
/*
|
|
||||||
Convert PDF2JSON coordinates to pdf-lib coordinates:
|
|
||||||
|
|
||||||
PDF2JSON uses relative "page units":
|
|
||||||
- x, y, width, height are in page units
|
|
||||||
- Page dimensions (Width, Height) are also in page units
|
|
||||||
|
|
||||||
pdf-lib uses absolute points (1 point = 1/72 inch):
|
|
||||||
- Need to convert from page units to points
|
|
||||||
- Y-axis in pdf-lib is bottom-up (origin at bottom-left)
|
|
||||||
- Y-axis in PDF2JSON is top-down (origin at top-left)
|
|
||||||
*/
|
|
||||||
|
|
||||||
const xPoints = (placeholder.x / placeholder.pageWidth) * pdfLibPageWidth;
|
|
||||||
const yPoints = pdfLibPageHeight - (placeholder.y / placeholder.pageHeight) * pdfLibPageHeight;
|
|
||||||
const widthPoints = (placeholder.width / placeholder.pageWidth) * pdfLibPageWidth;
|
|
||||||
const heightPoints = (placeholder.height / placeholder.pageHeight) * pdfLibPageHeight;
|
|
||||||
|
|
||||||
page.drawRectangle({
|
|
||||||
x: xPoints,
|
|
||||||
y: yPoints - heightPoints, // Adjust for height since y is at baseline
|
|
||||||
width: widthPoints,
|
|
||||||
height: heightPoints,
|
|
||||||
color: rgb(1, 1, 1),
|
|
||||||
borderColor: rgb(1, 1, 1),
|
|
||||||
borderWidth: 2,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const modifiedPdfBytes = await pdfDoc.save();
|
|
||||||
|
|
||||||
return Buffer.from(modifiedPdfBytes);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const insertFieldsFromPlaceholdersInPDF = async (
|
|
||||||
pdf: Buffer,
|
|
||||||
userId: number,
|
|
||||||
teamId: number,
|
|
||||||
envelopeId: EnvelopeIdOptions,
|
|
||||||
requestMetadata: ApiRequestMetadata,
|
|
||||||
envelopeItemId?: string,
|
|
||||||
recipients?: Pick<Recipient, 'id' | 'email'>[],
|
|
||||||
): Promise<Buffer> => {
|
|
||||||
const placeholders = await extractPlaceholdersFromPDF(pdf);
|
|
||||||
|
|
||||||
if (placeholders.length === 0) {
|
|
||||||
return pdf;
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
A structure that maps the recipient index to the recipient name.
|
|
||||||
Example: 1 => 'Recipient 1'
|
|
||||||
*/
|
|
||||||
const recipientPlaceholders = new Map<number, string>();
|
|
||||||
|
|
||||||
for (const placeholder of placeholders) {
|
|
||||||
const { name, recipientIndex } = extractRecipientPlaceholder(placeholder.recipient);
|
|
||||||
|
|
||||||
recipientPlaceholders.set(recipientIndex, name);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { envelopeWhereInput } = await getEnvelopeWhereInput({
|
|
||||||
id: envelopeId,
|
|
||||||
userId,
|
|
||||||
teamId,
|
|
||||||
type: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
const envelope = await prisma.envelope.findFirst({
|
|
||||||
where: envelopeWhereInput,
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
type: true,
|
|
||||||
secondaryId: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!envelope) {
|
|
||||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
|
||||||
message: 'Envelope not found',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const createdRecipients = await determineRecipientsForPlaceholders(
|
|
||||||
recipients,
|
|
||||||
recipientPlaceholders,
|
|
||||||
envelope,
|
|
||||||
userId,
|
|
||||||
teamId,
|
|
||||||
requestMetadata,
|
|
||||||
);
|
|
||||||
|
|
||||||
const fieldsToCreate: FieldToCreate[] = [];
|
|
||||||
|
|
||||||
for (const placeholder of placeholders) {
|
|
||||||
/*
|
|
||||||
Convert PDF2JSON coordinates to percentage-based coordinates (0-100)
|
|
||||||
The UI expects positionX and positionY as percentages, not absolute points
|
|
||||||
PDF2JSON uses relative coordinates: x/pageWidth and y/pageHeight give us the percentage
|
|
||||||
*/
|
|
||||||
const xPercent = (placeholder.x / placeholder.pageWidth) * 100;
|
|
||||||
const yPercent = (placeholder.y / placeholder.pageHeight) * 100;
|
|
||||||
|
|
||||||
const widthPercent = (placeholder.width / placeholder.pageWidth) * 100;
|
|
||||||
const heightPercent = (placeholder.height / placeholder.pageHeight) * 100;
|
|
||||||
|
|
||||||
const recipient = findRecipientByPlaceholder(
|
|
||||||
placeholder.recipient,
|
|
||||||
placeholder.placeholder,
|
|
||||||
recipients,
|
|
||||||
createdRecipients,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Default height percentage if too small (use 2% as a reasonable default)
|
|
||||||
const finalHeightPercent =
|
|
||||||
heightPercent > MIN_HEIGHT_THRESHOLD ? heightPercent : DEFAULT_FIELD_HEIGHT_PERCENT;
|
|
||||||
|
|
||||||
fieldsToCreate.push({
|
|
||||||
...placeholder.fieldAndMeta,
|
|
||||||
envelopeItemId,
|
|
||||||
recipientId: recipient.id,
|
|
||||||
page: placeholder.page,
|
|
||||||
positionX: xPercent,
|
|
||||||
positionY: yPercent,
|
|
||||||
width: widthPercent,
|
|
||||||
height: finalHeightPercent,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await createEnvelopeFields({
|
|
||||||
userId,
|
|
||||||
teamId,
|
|
||||||
id: envelopeId,
|
|
||||||
fields: fieldsToCreate,
|
|
||||||
requestMetadata,
|
|
||||||
});
|
|
||||||
|
|
||||||
return pdf;
|
|
||||||
};
|
|
||||||
@ -1,266 +0,0 @@
|
|||||||
import { FieldType } from '@prisma/client';
|
|
||||||
import { type Envelope, EnvelopeType, RecipientRole } from '@prisma/client';
|
|
||||||
import type { Recipient } from '@prisma/client';
|
|
||||||
import { match } from 'ts-pattern';
|
|
||||||
|
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
|
||||||
import { createEnvelopeRecipients } from '@documenso/lib/server-only/recipient/create-envelope-recipients';
|
|
||||||
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
|
||||||
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
|
|
||||||
import type { EnvelopeIdOptions } from '@documenso/lib/utils/envelope';
|
|
||||||
import { prisma } from '@documenso/prisma';
|
|
||||||
|
|
||||||
type RecipientPlaceholderInfo = {
|
|
||||||
email: string;
|
|
||||||
name: string;
|
|
||||||
recipientIndex: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
/*
|
|
||||||
Parse field type string to FieldType enum.
|
|
||||||
Normalizes the input (uppercase, trim) and validates it's a valid field type.
|
|
||||||
This ensures we handle case variations and whitespace, and provides clear error messages.
|
|
||||||
*/
|
|
||||||
export const parseFieldTypeFromPlaceholder = (fieldTypeString: string): FieldType => {
|
|
||||||
const normalizedType = fieldTypeString.toUpperCase().trim();
|
|
||||||
|
|
||||||
return match(normalizedType)
|
|
||||||
.with('SIGNATURE', () => FieldType.SIGNATURE)
|
|
||||||
.with('FREE_SIGNATURE', () => FieldType.FREE_SIGNATURE)
|
|
||||||
.with('INITIALS', () => FieldType.INITIALS)
|
|
||||||
.with('NAME', () => FieldType.NAME)
|
|
||||||
.with('EMAIL', () => FieldType.EMAIL)
|
|
||||||
.with('DATE', () => FieldType.DATE)
|
|
||||||
.with('TEXT', () => FieldType.TEXT)
|
|
||||||
.with('NUMBER', () => FieldType.NUMBER)
|
|
||||||
.with('RADIO', () => FieldType.RADIO)
|
|
||||||
.with('CHECKBOX', () => FieldType.CHECKBOX)
|
|
||||||
.with('DROPDOWN', () => FieldType.DROPDOWN)
|
|
||||||
.otherwise(() => {
|
|
||||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
|
||||||
message: `Invalid field type: ${fieldTypeString}`,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/*
|
|
||||||
Transform raw field metadata from placeholder format to schema format.
|
|
||||||
Users should provide properly capitalized property names (e.g., readOnly, fontSize, textAlign).
|
|
||||||
Converts string values to proper types (booleans, numbers).
|
|
||||||
*/
|
|
||||||
export const parseFieldMetaFromPlaceholder = (
|
|
||||||
rawFieldMeta: Record<string, string>,
|
|
||||||
fieldType: FieldType,
|
|
||||||
): Record<string, unknown> | undefined => {
|
|
||||||
if (fieldType === FieldType.SIGNATURE || fieldType === FieldType.FREE_SIGNATURE) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(rawFieldMeta).length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fieldTypeString = String(fieldType).toLowerCase();
|
|
||||||
|
|
||||||
const parsedFieldMeta: Record<string, boolean | number | string> = {
|
|
||||||
type: fieldTypeString,
|
|
||||||
};
|
|
||||||
|
|
||||||
/*
|
|
||||||
rawFieldMeta is an object with string keys and string values.
|
|
||||||
It contains string values because the PDF parser returns the values as strings.
|
|
||||||
|
|
||||||
E.g. { 'required': 'true', 'fontSize': '12', 'maxValue': '100', 'minValue': '0', 'characterLimit': '100' }
|
|
||||||
*/
|
|
||||||
const rawFieldMetaEntries = Object.entries(rawFieldMeta);
|
|
||||||
|
|
||||||
for (const [property, value] of rawFieldMetaEntries) {
|
|
||||||
if (property === 'readOnly' || property === 'required') {
|
|
||||||
parsedFieldMeta[property] = value === 'true';
|
|
||||||
} else if (
|
|
||||||
property === 'fontSize' ||
|
|
||||||
property === 'maxValue' ||
|
|
||||||
property === 'minValue' ||
|
|
||||||
property === 'characterLimit'
|
|
||||||
) {
|
|
||||||
const numValue = Number(value);
|
|
||||||
|
|
||||||
if (!Number.isNaN(numValue)) {
|
|
||||||
parsedFieldMeta[property] = numValue;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
parsedFieldMeta[property] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return parsedFieldMeta;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const extractRecipientPlaceholder = (placeholder: string): RecipientPlaceholderInfo => {
|
|
||||||
const indexMatch = placeholder.match(/^r(\d+)$/i);
|
|
||||||
|
|
||||||
if (!indexMatch) {
|
|
||||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
|
||||||
message: `Invalid recipient placeholder format: ${placeholder}. Expected format: r1, r2, r3, etc.`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const recipientIndex = Number(indexMatch[1]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
email: `recipient.${recipientIndex}@documenso.com`,
|
|
||||||
name: `Recipient ${recipientIndex}`,
|
|
||||||
recipientIndex,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/*
|
|
||||||
Finds a recipient based on a placeholder reference.
|
|
||||||
If recipients array is provided, uses index-based matching (r1 -> recipients[0], etc.).
|
|
||||||
Otherwise, uses email-based matching from createdRecipients.
|
|
||||||
*/
|
|
||||||
export const findRecipientByPlaceholder = (
|
|
||||||
recipientPlaceholder: string,
|
|
||||||
placeholder: string,
|
|
||||||
recipients: Pick<Recipient, 'id' | 'email'>[] | undefined,
|
|
||||||
createdRecipients: Pick<Recipient, 'id' | 'email'>[],
|
|
||||||
): Pick<Recipient, 'id' | 'email'> => {
|
|
||||||
if (recipients && recipients.length > 0) {
|
|
||||||
/*
|
|
||||||
Map placeholder by index: r1 -> recipients[0], r2 -> recipients[1], etc.
|
|
||||||
recipientIndex is 1-based, so we subtract 1 to get the array index.
|
|
||||||
*/
|
|
||||||
const { recipientIndex } = extractRecipientPlaceholder(recipientPlaceholder);
|
|
||||||
const recipientArrayIndex = recipientIndex - 1;
|
|
||||||
|
|
||||||
if (recipientArrayIndex < 0 || recipientArrayIndex >= recipients.length) {
|
|
||||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
|
||||||
message: `Recipient placeholder ${recipientPlaceholder} (index ${recipientIndex}) is out of range. Provided ${recipients.length} recipient(s).`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return recipients[recipientArrayIndex];
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
Use email-based matching for placeholder recipients.
|
|
||||||
*/
|
|
||||||
const { email } = extractRecipientPlaceholder(recipientPlaceholder);
|
|
||||||
const recipient = createdRecipients.find((r) => r.email === email);
|
|
||||||
|
|
||||||
if (!recipient) {
|
|
||||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
|
||||||
message: `Could not find recipient ID for placeholder: ${placeholder}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return recipient;
|
|
||||||
};
|
|
||||||
|
|
||||||
/*
|
|
||||||
Determines the recipients to use for field creation.
|
|
||||||
If recipients are provided, uses them directly.
|
|
||||||
Otherwise, creates recipients from placeholders.
|
|
||||||
*/
|
|
||||||
export const determineRecipientsForPlaceholders = async (
|
|
||||||
recipients: Pick<Recipient, 'id' | 'email'>[] | undefined,
|
|
||||||
recipientPlaceholders: Map<number, string>,
|
|
||||||
envelope: Pick<Envelope, 'id' | 'type' | 'secondaryId'>,
|
|
||||||
userId: number,
|
|
||||||
teamId: number,
|
|
||||||
requestMetadata: ApiRequestMetadata,
|
|
||||||
): Promise<Pick<Recipient, 'id' | 'email'>[]> => {
|
|
||||||
if (recipients && recipients.length > 0) {
|
|
||||||
return recipients;
|
|
||||||
}
|
|
||||||
|
|
||||||
return createRecipientsFromPlaceholders(
|
|
||||||
recipientPlaceholders,
|
|
||||||
envelope,
|
|
||||||
userId,
|
|
||||||
teamId,
|
|
||||||
requestMetadata,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createRecipientsFromPlaceholders = async (
|
|
||||||
recipientPlaceholders: Map<number, string>,
|
|
||||||
envelope: Pick<Envelope, 'id' | 'type' | 'secondaryId'>,
|
|
||||||
userId: number,
|
|
||||||
teamId: number,
|
|
||||||
requestMetadata: ApiRequestMetadata,
|
|
||||||
): Promise<Pick<Recipient, 'id' | 'email'>[]> => {
|
|
||||||
const recipientsToCreate = Array.from(
|
|
||||||
recipientPlaceholders.entries(),
|
|
||||||
([recipientIndex, name]) => {
|
|
||||||
return {
|
|
||||||
email: `recipient.${recipientIndex}@documenso.com`,
|
|
||||||
name,
|
|
||||||
role: RecipientRole.SIGNER,
|
|
||||||
signingOrder: recipientIndex,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const existingRecipients = await prisma.recipient.findMany({
|
|
||||||
where: {
|
|
||||||
envelopeId: envelope.id,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
email: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const existingEmails = new Set(existingRecipients.map((r) => r.email));
|
|
||||||
const recipientsToCreateFiltered = recipientsToCreate.filter(
|
|
||||||
(recipient) => !existingEmails.has(recipient.email),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (recipientsToCreateFiltered.length === 0) {
|
|
||||||
return existingRecipients;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newRecipients = await match(envelope.type)
|
|
||||||
.with(EnvelopeType.DOCUMENT, async () => {
|
|
||||||
const envelopeId: EnvelopeIdOptions = {
|
|
||||||
type: 'envelopeId',
|
|
||||||
id: envelope.id,
|
|
||||||
};
|
|
||||||
|
|
||||||
const { recipients } = await createEnvelopeRecipients({
|
|
||||||
userId,
|
|
||||||
teamId,
|
|
||||||
id: envelopeId,
|
|
||||||
recipients: recipientsToCreateFiltered,
|
|
||||||
requestMetadata,
|
|
||||||
});
|
|
||||||
|
|
||||||
return recipients;
|
|
||||||
})
|
|
||||||
.with(EnvelopeType.TEMPLATE, async () => {
|
|
||||||
const templateId = mapSecondaryIdToTemplateId(envelope.secondaryId ?? '');
|
|
||||||
|
|
||||||
const envelopeId: EnvelopeIdOptions = {
|
|
||||||
type: 'templateId',
|
|
||||||
id: templateId,
|
|
||||||
};
|
|
||||||
|
|
||||||
const { recipients } = await createEnvelopeRecipients({
|
|
||||||
userId,
|
|
||||||
teamId,
|
|
||||||
id: envelopeId,
|
|
||||||
recipients: recipientsToCreateFiltered,
|
|
||||||
requestMetadata,
|
|
||||||
});
|
|
||||||
|
|
||||||
return recipients;
|
|
||||||
})
|
|
||||||
.otherwise(() => {
|
|
||||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
|
||||||
message: `Invalid envelope type: ${envelope.type}`,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return [...existingRecipients, ...newRecipients];
|
|
||||||
};
|
|
||||||
@ -23,7 +23,5 @@ export const normalizePdf = async (pdf: Buffer) => {
|
|||||||
await flattenForm(pdfDoc);
|
await flattenForm(pdfDoc);
|
||||||
flattenAnnotations(pdfDoc);
|
flattenAnnotations(pdfDoc);
|
||||||
|
|
||||||
const normalizedPdfBytes = await pdfDoc.save();
|
return Buffer.from(await pdfDoc.save());
|
||||||
|
|
||||||
return Buffer.from(normalizedPdfBytes);
|
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user