documenso/packages/lib/server-only/template/create-document-from-template.ts

607 lines
19 KiB
TypeScript
Raw Normal View History

import type { DocumentDistributionMethod, DocumentSigningOrder } from '@prisma/client';
import {
DocumentSource,
type Field,
FolderType,
type Recipient,
RecipientRole,
SendStatus,
SigningStatus,
WebhookTriggerEvents,
2025-01-02 04:33:37 +00:00
} from '@prisma/client';
import { DateTime } from 'luxon';
import { match } from 'ts-pattern';
2025-01-02 04:33:37 +00:00
import { nanoid, prefixedId } from '@documenso/lib/universal/id';
2025-01-02 04:33:37 +00:00
import { prisma } from '@documenso/prisma';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../../constants/date-formats';
import type { SupportedLanguageCodes } from '../../constants/i18n';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { ZRecipientAuthOptionsSchema } from '../../types/document-auth';
import type { TDocumentEmailSettings } from '../../types/document-email';
import type {
TCheckboxFieldMeta,
TDropdownFieldMeta,
TFieldMetaPrefillFieldsSchema,
TNumberFieldMeta,
TRadioFieldMeta,
TTextFieldMeta,
} from '../../types/field-meta';
import {
ZCheckboxFieldMeta,
ZDropdownFieldMeta,
ZFieldMetaSchema,
ZRadioFieldMeta,
} from '../../types/field-meta';
2025-01-13 02:41:53 +00:00
import {
ZWebhookDocumentSchema,
mapDocumentToWebhookDocumentPayload,
} from '../../types/webhook-payload';
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
import { extractDerivedDocumentMeta } from '../../utils/document';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import {
createDocumentAuthOptions,
createRecipientAuthOptions,
extractDocumentAuthMethods,
} from '../../utils/document-auth';
2025-06-10 01:49:52 +00:00
import { buildTeamWhereQuery } from '../../utils/teams';
import { getTeamSettings } from '../team/get-team-settings';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
type FinalRecipient = Pick<
Recipient,
'name' | 'email' | 'role' | 'authOptions' | 'signingOrder'
> & {
templateRecipientId: number;
fields: Field[];
};
2023-10-06 22:54:24 +00:00
2024-02-20 08:46:18 +00:00
export type CreateDocumentFromTemplateOptions = {
templateId: number;
externalId?: string | null;
2023-10-06 22:54:24 +00:00
userId: number;
2025-06-10 01:49:52 +00:00
teamId: number;
recipients: {
id: number;
2024-02-20 08:46:18 +00:00
name?: string;
email: string;
signingOrder?: number | null;
2024-02-20 08:46:18 +00:00
}[];
folderId?: string;
prefillFields?: TFieldMetaPrefillFieldsSchema[];
customDocumentDataId?: string;
/**
* Values that will override the predefined values in the template.
*/
override?: {
title?: string;
subject?: string;
message?: string;
timezone?: string;
password?: string;
dateFormat?: string;
redirectUrl?: string;
signingOrder?: DocumentSigningOrder;
language?: SupportedLanguageCodes;
distributionMethod?: DocumentDistributionMethod;
allowDictateNextSigner?: boolean;
emailSettings?: TDocumentEmailSettings;
typedSignatureEnabled?: boolean;
uploadSignatureEnabled?: boolean;
drawSignatureEnabled?: boolean;
};
requestMetadata: ApiRequestMetadata;
2023-10-06 22:54:24 +00:00
};
const getUpdatedFieldMeta = (field: Field, prefillField?: TFieldMetaPrefillFieldsSchema) => {
if (!prefillField) {
return field.fieldMeta;
}
const advancedField = ['NUMBER', 'RADIO', 'CHECKBOX', 'DROPDOWN', 'TEXT'].includes(field.type);
if (!advancedField) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Field ${field.id} is not an advanced field and cannot have field meta information. Allowed types: NUMBER, RADIO, CHECKBOX, DROPDOWN, TEXT.`,
});
}
// We've already validated that the field types match at a higher level
// Start with the existing field meta or an empty object
const existingMeta = field.fieldMeta || {};
// Apply type-specific updates based on the prefill field type using ts-pattern
return match(prefillField)
.with({ type: 'text' }, (field) => {
if (typeof field.value !== 'string') {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Invalid value for TEXT field ${field.id}: expected string, got ${typeof field.value}`,
});
}
const meta: TTextFieldMeta = {
...existingMeta,
type: 'text',
label: field.label,
placeholder: field.placeholder,
text: field.value,
};
return meta;
})
.with({ type: 'number' }, (field) => {
if (typeof field.value !== 'string') {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Invalid value for NUMBER field ${field.id}: expected string, got ${typeof field.value}`,
});
}
const meta: TNumberFieldMeta = {
...existingMeta,
type: 'number',
label: field.label,
placeholder: field.placeholder,
value: field.value,
};
return meta;
})
.with({ type: 'radio' }, (field) => {
if (typeof field.value !== 'string') {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Invalid value for RADIO field ${field.id}: expected string, got ${typeof field.value}`,
});
}
const result = ZRadioFieldMeta.safeParse(existingMeta);
if (!result.success) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Invalid field meta for RADIO field ${field.id}`,
});
}
const radioMeta = result.data;
// Validate that the value exists in the options
const valueExists = radioMeta.values?.some((option) => option.value === field.value);
if (!valueExists) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Value "${field.value}" not found in options for RADIO field ${field.id}`,
});
}
const newValues = radioMeta.values?.map((option) => ({
...option,
checked: option.value === field.value,
}));
const meta: TRadioFieldMeta = {
...existingMeta,
type: 'radio',
label: field.label,
values: newValues,
};
return meta;
})
.with({ type: 'checkbox' }, (field) => {
const result = ZCheckboxFieldMeta.safeParse(existingMeta);
if (!result.success) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Invalid field meta for CHECKBOX field ${field.id}`,
});
}
const checkboxMeta = result.data;
if (!field.value) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Value is required for CHECKBOX field ${field.id}`,
});
}
const fieldValue = field.value;
// Validate that all values exist in the options
for (const value of fieldValue) {
const valueExists = checkboxMeta.values?.some((option) => option.value === value);
if (!valueExists) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Value "${value}" not found in options for CHECKBOX field ${field.id}`,
});
}
}
const newValues = checkboxMeta.values?.map((option) => ({
...option,
checked: fieldValue.includes(option.value),
}));
const meta: TCheckboxFieldMeta = {
...existingMeta,
type: 'checkbox',
label: field.label,
values: newValues,
direction: checkboxMeta.direction ?? 'vertical',
};
return meta;
})
.with({ type: 'dropdown' }, (field) => {
const result = ZDropdownFieldMeta.safeParse(existingMeta);
if (!result.success) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Invalid field meta for DROPDOWN field ${field.id}`,
});
}
const dropdownMeta = result.data;
// Validate that the value exists in the options if values are defined
const valueExists = dropdownMeta.values?.some((option) => option.value === field.value);
if (!valueExists) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Value "${field.value}" not found in options for DROPDOWN field ${field.id}`,
});
}
const meta: TDropdownFieldMeta = {
...existingMeta,
type: 'dropdown',
label: field.label,
defaultValue: field.value,
};
return meta;
})
.otherwise(() => field.fieldMeta);
};
2023-10-06 22:54:24 +00:00
export const createDocumentFromTemplate = async ({
templateId,
externalId,
2023-10-06 22:54:24 +00:00
userId,
2024-02-22 02:39:34 +00:00
teamId,
2024-02-20 08:46:18 +00:00
recipients,
customDocumentDataId,
override,
requestMetadata,
folderId,
prefillFields,
}: CreateDocumentFromTemplateOptions) => {
2023-10-06 22:54:24 +00:00
const template = await prisma.template.findUnique({
2024-02-08 01:33:20 +00:00
where: {
id: templateId,
2025-06-10 01:49:52 +00:00
team: buildTeamWhereQuery({ teamId, userId }),
2024-02-08 01:33:20 +00:00
},
2023-10-06 22:54:24 +00:00
include: {
2025-01-13 02:41:53 +00:00
recipients: {
include: {
2025-01-13 02:41:53 +00:00
fields: true,
},
},
2023-10-06 22:54:24 +00:00
templateDocumentData: true,
templateMeta: true,
2023-10-06 22:54:24 +00:00
},
});
if (!template) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Template not found',
});
2023-10-06 22:54:24 +00:00
}
if (folderId) {
const folder = await prisma.folder.findUnique({
where: {
id: folderId,
type: FolderType.DOCUMENT,
team: buildTeamWhereQuery({ teamId, userId }),
},
});
if (!folder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Folder not found',
});
}
}
2025-06-10 01:49:52 +00:00
const settings = await getTeamSettings({
userId,
teamId,
});
// Check that all the passed in recipient IDs can be associated with a template recipient.
recipients.forEach((recipient) => {
2025-01-13 02:41:53 +00:00
const foundRecipient = template.recipients.find(
(templateRecipient) => templateRecipient.id === recipient.id,
);
if (!foundRecipient) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Recipient with ID ${recipient.id} not found in the template.`,
});
}
});
const { documentAuthOption: templateAuthOptions } = extractDocumentAuthMethods({
documentAuth: template.authOptions,
});
2025-01-13 02:41:53 +00:00
const finalRecipients: FinalRecipient[] = template.recipients.map((templateRecipient) => {
const foundRecipient = recipients.find((recipient) => recipient.id === templateRecipient.id);
return {
templateRecipientId: templateRecipient.id,
2025-01-13 02:41:53 +00:00
fields: templateRecipient.fields,
name: foundRecipient ? (foundRecipient.name ?? '') : templateRecipient.name,
email: foundRecipient ? foundRecipient.email : templateRecipient.email,
role: templateRecipient.role,
signingOrder: foundRecipient?.signingOrder ?? templateRecipient.signingOrder,
authOptions: templateRecipient.authOptions,
};
});
let parentDocumentData = template.templateDocumentData;
if (customDocumentDataId) {
const customDocumentData = await prisma.documentData.findFirst({
where: {
id: customDocumentDataId,
},
});
if (!customDocumentData) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Custom document data not found',
});
}
parentDocumentData = customDocumentData;
}
2023-10-06 22:54:24 +00:00
const documentData = await prisma.documentData.create({
data: {
type: parentDocumentData.type,
data: parentDocumentData.data,
initialData: parentDocumentData.initialData,
2023-10-06 22:54:24 +00:00
},
});
return await prisma.$transaction(async (tx) => {
const document = await tx.document.create({
data: {
qrToken: prefixedId('qr'),
source: DocumentSource.TEMPLATE,
externalId: externalId || template.externalId,
templateId: template.id,
userId,
folderId,
teamId: template.teamId,
title: override?.title || template.title,
documentDataId: documentData.id,
authOptions: createDocumentAuthOptions({
globalAccessAuth: templateAuthOptions.globalAccessAuth,
globalActionAuth: templateAuthOptions.globalActionAuth,
}),
2025-06-10 01:49:52 +00:00
visibility: template.visibility || settings.documentVisibility,
fix: rework fields (#1697) Rework: - Field styling to improve visibility - Field insertions, better alignment, centering and overflows ## Changes General changes: - Set default text alignment to left if no meta found - Reduce borders and rings around fields to allow smaller fields - Removed lots of redundant duplicated code surrounding field rendering - Make fields more consistent across viewing, editing and signing - Add more transparency to fields to allow users to see under fields - No more optional/required/etc colors when signing, required fields will be highlighted as orange when form is "validating" Highlighted internal changes: - Utilize native PDF fields to insert text, instead of drawing text - Change font auto scaling to only apply to when the height overflows AND no custom font is set ⚠️ Multiline changes: Multi line is enabled for a field under these conditions 1. Field content exceeds field width 2. Field includes a new line 3. Field type is TEXT ## [BEFORE] Field UI Signing ![image](https://github.com/user-attachments/assets/ea002743-faeb-477c-a239-3ed240b54f55) ## [AFTER] Field UI Signing ![image](https://github.com/user-attachments/assets/0f8eb252-4cf3-4d96-8d4f-cd085881b78c) ## [BEFORE] Signing a checkbox ![image](https://github.com/user-attachments/assets/4567d745-e1da-4202-a758-5d3c178c930e) ![image](https://github.com/user-attachments/assets/c25068e7-fe80-40f5-b63a-e8a0d4b38b6c) ## [AFTER] Signing a checkbox ![image](https://github.com/user-attachments/assets/effa5e3d-385a-4c35-bc8a-405386dd27d6) ![image](https://github.com/user-attachments/assets/64be34a9-0b32-424d-9264-15361c03eca5) ## [BEFORE] What a 2nd recipient sees once someone else signed a document ![image](https://github.com/user-attachments/assets/21c21ae2-fc62-4ccc-880a-46aab012aa70) ## [AFTER] What a 2nd recipient sees once someone else signed a document ![image](https://github.com/user-attachments/assets/ae51677b-f1d5-4008-a7fd-756533166542) ## **[BEFORE]** Inserting fields ![image](https://github.com/user-attachments/assets/1a8bb8da-9a15-4deb-bc28-eb349414465c) ## **[AFTER]** Inserting fields ![image](https://github.com/user-attachments/assets/c52c5238-9836-45aa-b8a4-bc24a3462f40) ## Overflows, multilines and field alignments testing Debugging borders: - Red border = The original field placement without any modifications - Blue border = The available space to overflow ### Single line overflows and field alignments This is left aligned fields, overflow will always go to the end of the page and will not wrap ![image](https://github.com/user-attachments/assets/47003658-783e-4f9c-adbf-c4686804d98f) This is center aligned fields, the max width is the closest edge to the page * 2 ![image](https://github.com/user-attachments/assets/05a38093-75d6-4600-bae2-21ecff63e115) This is right aligned text, the width will extend all the way to the left hand side of the page ![image](https://github.com/user-attachments/assets/6a9d84a8-4166-4626-9fb3-1577fac2571e) ### Multiline line overflows and field alignments These are text fields that can be overflowed ![image](https://github.com/user-attachments/assets/f7b5456e-2c49-48b2-8d4c-ab1dc3401644) Another example of left aligned text overflows with more text ![image](https://github.com/user-attachments/assets/3db6b35e-4c8d-4ffe-8036-0da760d9c167)
2025-04-23 11:40:42 +00:00
useLegacyFieldInsertion: template.useLegacyFieldInsertion ?? false,
documentMeta: {
create: extractDerivedDocumentMeta(settings, {
subject: override?.subject || template.templateMeta?.subject,
message: override?.message || template.templateMeta?.message,
timezone: override?.timezone || template.templateMeta?.timezone,
password: override?.password || template.templateMeta?.password,
dateFormat: override?.dateFormat || template.templateMeta?.dateFormat,
redirectUrl: override?.redirectUrl || template.templateMeta?.redirectUrl,
distributionMethod:
override?.distributionMethod || template.templateMeta?.distributionMethod,
emailSettings: override?.emailSettings || template.templateMeta?.emailSettings,
signingOrder: override?.signingOrder || template.templateMeta?.signingOrder,
feat: add global settings for teams (#1391) ## Description This PR introduces global settings for teams. At the moment, it allows team admins to configure the following: * The default visibility of the documents uploaded to the team account * Whether to include the document owner (sender) details when sending emails to the recipients. ### Include Sender Details If the Sender Details setting is enabled, the emails sent by the team will include the sender's name: > "Example User" on behalf of "Example Team" has invited you to sign "document.pdf" Otherwise, the email will say: > "Example Team" has invited you to sign "document.pdf" ### Default Document Visibility This new option allows users to set the default visibility for the documents uploaded to the team account. It can have the following values: * Everyone * Manager and above * Admins only If the default document visibility isn't set, the document will be set to the role of the user who created the document: * If a user with the "User" role creates a document, the document's visibility is set to "Everyone". * Manager role -> "Manager and above" * Admin role -> "Admins only" Otherwise, if there is a default document visibility value, it uses that value. #### Gotcha To avoid issues, the `document owner` and the `recipient` can access the document irrespective of their role. For example: * If a team member with the role "Member" uploads a document and the default document visibility is "Admins", only the document owner and admins can access the document. * Similar to the other scenarios. * If an admin uploads a document and the default document visibility is "Admins", the recipient can access the document. * The admins have access to all the documents. * Managers have access to documents with the visibility set to "Everyone" and "Manager and above" * Members have access only to the documents with the visibility set to "Everyone". ## Testing Performed Tested it locally.
2024-11-08 11:50:49 +00:00
language:
2025-06-10 01:49:52 +00:00
override?.language || template.templateMeta?.language || settings.documentLanguage,
typedSignatureEnabled:
override?.typedSignatureEnabled ?? template.templateMeta?.typedSignatureEnabled,
uploadSignatureEnabled:
override?.uploadSignatureEnabled ?? template.templateMeta?.uploadSignatureEnabled,
drawSignatureEnabled:
override?.drawSignatureEnabled ?? template.templateMeta?.drawSignatureEnabled,
allowDictateNextSigner:
override?.allowDictateNextSigner ?? template.templateMeta?.allowDictateNextSigner,
}),
},
2025-01-13 02:41:53 +00:00
recipients: {
createMany: {
data: finalRecipients.map((recipient) => {
const authOptions = ZRecipientAuthOptionsSchema.parse(recipient?.authOptions);
return {
email: recipient.email,
name: recipient.name,
role: recipient.role,
authOptions: createRecipientAuthOptions({
accessAuth: authOptions.accessAuth,
actionAuth: authOptions.actionAuth,
}),
sendStatus:
recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT,
signingStatus:
recipient.role === RecipientRole.CC
? SigningStatus.SIGNED
: SigningStatus.NOT_SIGNED,
signingOrder: recipient.signingOrder,
token: nanoid(),
};
}),
},
},
2023-10-06 22:54:24 +00:00
},
include: {
2025-01-13 02:41:53 +00:00
recipients: {
orderBy: {
id: 'asc',
},
2024-02-20 08:46:18 +00:00
},
documentData: true,
2024-02-20 08:46:18 +00:00
},
});
2023-10-06 22:54:24 +00:00
let fieldsToCreate: Omit<Field, 'id' | 'secondaryId' | 'templateId'>[] = [];
2023-10-06 22:54:24 +00:00
// Get all template field IDs first so we can validate later
const allTemplateFieldIds = finalRecipients.flatMap((recipient) =>
recipient.fields.map((field) => field.id),
);
if (prefillFields?.length) {
// Validate that all prefill field IDs exist in the template
const invalidFieldIds = prefillFields
.map((prefillField) => prefillField.id)
.filter((id) => !allTemplateFieldIds.includes(id));
if (invalidFieldIds.length > 0) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `The following field IDs do not exist in the template: ${invalidFieldIds.join(', ')}`,
});
}
// Validate that all prefill fields have the correct type
for (const prefillField of prefillFields) {
const templateField = finalRecipients
.flatMap((recipient) => recipient.fields)
.find((field) => field.id === prefillField.id);
if (!templateField) {
// This should never happen due to the previous validation, but just in case
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Field with ID ${prefillField.id} not found in the template`,
});
}
const expectedType = templateField.type.toLowerCase();
const actualType = prefillField.type;
if (expectedType !== actualType) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Field type mismatch for field ${prefillField.id}: expected ${expectedType}, got ${actualType}`,
});
}
}
}
Object.values(finalRecipients).forEach(({ email, fields }) => {
2025-01-13 02:41:53 +00:00
const recipient = document.recipients.find((recipient) => recipient.email === email);
2023-10-06 22:54:24 +00:00
if (!recipient) {
2024-04-18 14:56:31 +00:00
throw new Error('Recipient not found.');
}
fieldsToCreate = fieldsToCreate.concat(
fields.map((field) => {
const prefillField = prefillFields?.find((value) => value.id === field.id);
const payload = {
documentId: document.id,
recipientId: recipient.id,
type: field.type,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
customText: '',
inserted: false,
fieldMeta: field.fieldMeta,
};
if (prefillField) {
match(prefillField)
.with({ type: 'date' }, (selector) => {
if (!selector.value) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Date value is required for field ${field.id}`,
});
}
const date = new Date(selector.value);
if (isNaN(date.getTime())) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Invalid date value for field ${field.id}: ${selector.value}`,
});
}
payload.customText = DateTime.fromJSDate(date).toFormat(
template.templateMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT,
);
payload.inserted = true;
})
.otherwise((selector) => {
payload.fieldMeta = getUpdatedFieldMeta(field, selector);
});
}
return payload;
}),
);
});
2023-10-06 22:54:24 +00:00
await tx.field.createMany({
data: fieldsToCreate.map((field) => ({
...field,
fieldMeta: field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined,
})),
});
2024-02-20 08:46:18 +00:00
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED,
documentId: document.id,
metadata: requestMetadata,
data: {
title: document.title,
source: {
type: DocumentSource.TEMPLATE,
templateId: template.id,
},
},
2024-02-20 08:46:18 +00:00
}),
});
2024-02-20 08:46:18 +00:00
const createdDocument = await tx.document.findFirst({
where: {
id: document.id,
},
include: {
documentMeta: true,
2025-01-13 02:41:53 +00:00
recipients: true,
},
});
if (!createdDocument) {
throw new Error('Document not found');
}
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_CREATED,
2025-01-13 02:41:53 +00:00
data: ZWebhookDocumentSchema.parse(mapDocumentToWebhookDocumentPayload(createdDocument)),
userId,
teamId,
});
return document;
});
2023-10-06 22:54:24 +00:00
};