mirror of
https://github.com/documenso/documenso.git
synced 2026-06-22 04:12:06 +10:00
d5ce222482
Adds Cloud Signature Consortium (CSC) integration for AES/QES signing against a configured TSP. v1 ships as instance-wide configuration via environment variables, with per-envelope signature level selection, license gating, and an OAuth-driven signing flow (capture + FIFO signers, SAD session, blocking/in-progress recipient pages). Includes signature level compatibility checks (role, signing order, dictate next signer), envelope mutability assertions, Prisma migration for signature level and CSC tables, and docs for the new signing certificate options.
800 lines
26 KiB
TypeScript
800 lines
26 KiB
TypeScript
import { nanoid, prefixedId } from '@documenso/lib/universal/id';
|
|
import { prisma } from '@documenso/prisma';
|
|
import type { DocumentDistributionMethod, DocumentSigningOrder } from '@prisma/client';
|
|
import {
|
|
DocumentSource,
|
|
EnvelopeType,
|
|
type Field,
|
|
FolderType,
|
|
type Recipient,
|
|
RecipientRole,
|
|
SendStatus,
|
|
SigningStatus,
|
|
WebhookTriggerEvents,
|
|
} from '@prisma/client';
|
|
import { DateTime } from 'luxon';
|
|
import { match } from 'ts-pattern';
|
|
|
|
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../../constants/date-formats';
|
|
import type { TEnvelopeExpirationPeriod } from '../../constants/envelope-expiration';
|
|
import type { SupportedLanguageCodes } from '../../constants/i18n';
|
|
import { AppError, AppErrorCode } from '../../errors/app-error';
|
|
import { ZDefaultRecipientsSchema } from '../../types/default-recipients';
|
|
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 { TDocumentFormValues } from '../../types/document-form-values';
|
|
import type {
|
|
TCheckboxFieldMeta,
|
|
TDropdownFieldMeta,
|
|
TFieldMetaPrefillFieldsSchema,
|
|
TNumberFieldMeta,
|
|
TRadioFieldMeta,
|
|
TTextFieldMeta,
|
|
} from '../../types/field-meta';
|
|
import { ZCheckboxFieldMeta, ZDropdownFieldMeta, ZFieldMetaSchema, ZRadioFieldMeta } from '../../types/field-meta';
|
|
import { ZSignatureLevelSchema } from '../../types/signature-level';
|
|
import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../types/webhook-payload';
|
|
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
|
|
import { getFileServerSide } from '../../universal/upload/get-file.server';
|
|
import { putNormalizedPdfFileServerSide } from '../../universal/upload/put-file.server';
|
|
import { extractDerivedDocumentMeta } from '../../utils/document';
|
|
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
|
import {
|
|
createDocumentAuthOptions,
|
|
createRecipientAuthOptions,
|
|
extractDocumentAuthMethods,
|
|
} from '../../utils/document-auth';
|
|
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
|
import { mapSecondaryIdToTemplateId } from '../../utils/envelope';
|
|
import { buildTeamWhereQuery } from '../../utils/teams';
|
|
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
|
import { incrementDocumentId } from '../envelope/increment-id';
|
|
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
|
|
import { resolveSignatureLevel } from '../signature-level/resolve-signature-level';
|
|
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
|
|
import { getTeamSettings } from '../team/get-team-settings';
|
|
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
|
import { getOrganisationTemplateWhereInput } from './get-organisation-template-by-id';
|
|
|
|
type FinalRecipient = Pick<Recipient, 'name' | 'email' | 'role' | 'authOptions' | 'signingOrder' | 'token'> & {
|
|
templateRecipientId: number;
|
|
fields: Field[];
|
|
};
|
|
|
|
export type CreateDocumentFromTemplateOptions = {
|
|
id: EnvelopeIdOptions;
|
|
externalId?: string | null;
|
|
userId: number;
|
|
teamId: number;
|
|
recipients: {
|
|
id: number;
|
|
name?: string;
|
|
email: string;
|
|
signingOrder?: number | null;
|
|
}[];
|
|
folderId?: string;
|
|
prefillFields?: TFieldMetaPrefillFieldsSchema[];
|
|
|
|
customDocumentData?: {
|
|
documentDataId: string;
|
|
|
|
/**
|
|
* The envelope item ID which will be updated to use the custom document data.
|
|
*
|
|
* If undefined, will use the first envelope item. This is done for backwards compatibility reasons.
|
|
*/
|
|
envelopeItemId?: string;
|
|
}[];
|
|
|
|
attachments?: Array<{
|
|
label: string;
|
|
data: string;
|
|
type?: 'link';
|
|
}>;
|
|
|
|
/**
|
|
* 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;
|
|
envelopeExpirationPeriod?: TEnvelopeExpirationPeriod | null;
|
|
};
|
|
|
|
formValues?: TDocumentFormValues;
|
|
requestMetadata: ApiRequestMetadata;
|
|
};
|
|
|
|
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,
|
|
direction: radioMeta.direction ?? 'vertical',
|
|
};
|
|
|
|
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);
|
|
};
|
|
|
|
export const createDocumentFromTemplate = async ({
|
|
id,
|
|
externalId,
|
|
userId,
|
|
teamId,
|
|
recipients,
|
|
customDocumentData = [],
|
|
override,
|
|
requestMetadata,
|
|
folderId,
|
|
prefillFields,
|
|
attachments,
|
|
formValues,
|
|
}: CreateDocumentFromTemplateOptions) => {
|
|
const templateInclude = {
|
|
recipients: {
|
|
include: {
|
|
fields: true,
|
|
},
|
|
},
|
|
envelopeItems: {
|
|
include: {
|
|
documentData: true,
|
|
},
|
|
},
|
|
documentMeta: true,
|
|
} as const;
|
|
|
|
const { envelopeWhereInput, team: callerTeam } = await getEnvelopeWhereInput({
|
|
id,
|
|
type: EnvelopeType.TEMPLATE,
|
|
userId,
|
|
teamId,
|
|
});
|
|
|
|
const [teamTemplate, organisationTemplate] = await Promise.all([
|
|
prisma.envelope.findFirst({
|
|
where: envelopeWhereInput,
|
|
include: templateInclude,
|
|
}),
|
|
prisma.envelope.findFirst({
|
|
where: getOrganisationTemplateWhereInput({
|
|
id,
|
|
organisationId: callerTeam.organisationId,
|
|
teamRole: callerTeam.currentTeamRole,
|
|
}),
|
|
include: templateInclude,
|
|
}),
|
|
]);
|
|
|
|
const template = teamTemplate ?? organisationTemplate;
|
|
|
|
if (!template) {
|
|
throw new AppError(AppErrorCode.NOT_FOUND, {
|
|
message: 'Template not found',
|
|
});
|
|
}
|
|
|
|
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',
|
|
});
|
|
}
|
|
}
|
|
|
|
const legacyTemplateId = mapSecondaryIdToTemplateId(template.secondaryId);
|
|
const finalEnvelopeTitle = override?.title || template.title;
|
|
|
|
if (template.envelopeItems.length < 1) {
|
|
throw new AppError(AppErrorCode.INVALID_BODY, {
|
|
message: 'Template must have at least 1 envelope item',
|
|
});
|
|
}
|
|
|
|
const settings = await getTeamSettings({
|
|
userId,
|
|
teamId,
|
|
});
|
|
|
|
// Check that all the passed in recipient IDs can be associated with a template recipient.
|
|
recipients.forEach((recipient) => {
|
|
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,
|
|
});
|
|
|
|
const finalRecipients: FinalRecipient[] = template.recipients.map((templateRecipient) => {
|
|
const foundRecipient = recipients.find((recipient) => recipient.id === templateRecipient.id);
|
|
|
|
return {
|
|
templateRecipientId: templateRecipient.id,
|
|
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,
|
|
token: nanoid(),
|
|
};
|
|
});
|
|
|
|
const defaultRecipients = settings.defaultRecipients
|
|
? ZDefaultRecipientsSchema.parse(settings.defaultRecipients)
|
|
: [];
|
|
|
|
const defaultRecipientsFinal: FinalRecipient[] = defaultRecipients.map((recipient) => {
|
|
const authOptions = ZRecipientAuthOptionsSchema.parse({});
|
|
|
|
return {
|
|
templateRecipientId: -1,
|
|
fields: [],
|
|
name: recipient.name || recipient.email,
|
|
email: recipient.email,
|
|
role: recipient.role,
|
|
signingOrder: null,
|
|
authOptions: createRecipientAuthOptions({
|
|
accessAuth: authOptions.accessAuth,
|
|
actionAuth: authOptions.actionAuth,
|
|
}),
|
|
token: nanoid(),
|
|
};
|
|
});
|
|
|
|
const allFinalRecipients = [...finalRecipients, ...defaultRecipientsFinal];
|
|
|
|
// Key = original envelope item ID
|
|
// Value = duplicated envelope item ID.
|
|
const oldEnvelopeItemToNewEnvelopeItemIdMap: Record<string, string> = {};
|
|
|
|
// Duplicate the envelope item data.
|
|
// Note: This is duplicated in createDocumentFromDirectTemplate
|
|
const envelopeItemsToCreate = await Promise.all(
|
|
template.envelopeItems.map(async (item, i) => {
|
|
let documentDataIdToDuplicate = item.documentDataId;
|
|
|
|
const foundCustomDocumentData = customDocumentData.find((customDocumentDataItem) => {
|
|
// Handle empty envelopeItemId for backwards compatibility reasons.
|
|
if (customDocumentDataItem.documentDataId && !customDocumentDataItem.envelopeItemId) {
|
|
return true;
|
|
}
|
|
|
|
return customDocumentDataItem.envelopeItemId === item.id;
|
|
});
|
|
|
|
if (foundCustomDocumentData) {
|
|
documentDataIdToDuplicate = foundCustomDocumentData.documentDataId;
|
|
}
|
|
|
|
const documentDataToDuplicate = await prisma.documentData.findFirst({
|
|
where: {
|
|
id: documentDataIdToDuplicate,
|
|
},
|
|
});
|
|
|
|
if (!documentDataToDuplicate) {
|
|
throw new AppError(AppErrorCode.NOT_FOUND, {
|
|
message: 'Document data not found',
|
|
});
|
|
}
|
|
|
|
let buffer = await getFileServerSide(documentDataToDuplicate);
|
|
|
|
const titleToUse = item.title || finalEnvelopeTitle;
|
|
|
|
if (formValues) {
|
|
// eslint-disable-next-line require-atomic-updates
|
|
buffer = await insertFormValuesInPdf({
|
|
pdf: Buffer.from(buffer),
|
|
formValues,
|
|
});
|
|
}
|
|
|
|
const duplicatedFile = await putNormalizedPdfFileServerSide({
|
|
name: titleToUse,
|
|
type: 'application/pdf',
|
|
arrayBuffer: async () => Promise.resolve(buffer),
|
|
});
|
|
|
|
const newDocumentData = await prisma.documentData.create({
|
|
data: {
|
|
type: duplicatedFile.type,
|
|
data: duplicatedFile.data,
|
|
initialData: documentDataToDuplicate.data,
|
|
},
|
|
});
|
|
|
|
const newEnvelopeItemId = prefixedId('envelope_item');
|
|
|
|
oldEnvelopeItemToNewEnvelopeItemIdMap[item.id] = newEnvelopeItemId;
|
|
|
|
return {
|
|
id: newEnvelopeItemId,
|
|
title: titleToUse.endsWith('.pdf') ? titleToUse.slice(0, -4) : titleToUse,
|
|
documentDataId: newDocumentData.id,
|
|
order: item.order !== undefined ? item.order : i + 1,
|
|
};
|
|
}),
|
|
);
|
|
|
|
// Enforce the organisation document-creation limit before creating the document.
|
|
await assertOrganisationRatesAndLimits({
|
|
organisationId: callerTeam.organisationId,
|
|
type: 'document',
|
|
count: 1,
|
|
});
|
|
|
|
const incrementedDocumentId = await incrementDocumentId();
|
|
|
|
// Carry the template's level forward, coercing if the instance mode has
|
|
// changed since the template was created. ZSignatureLevelSchema parses the
|
|
// free-form TEXT column defensively. Resolved before meta extraction so
|
|
// signingOrder picks up the TSP-appropriate default + assertion.
|
|
const signatureLevel = resolveSignatureLevel({
|
|
requested: ZSignatureLevelSchema.parse(template.signatureLevel),
|
|
strict: false,
|
|
});
|
|
|
|
const documentMeta = await prisma.documentMeta.create({
|
|
data: extractDerivedDocumentMeta(
|
|
settings,
|
|
{
|
|
subject: override?.subject || template.documentMeta?.subject,
|
|
message: override?.message || template.documentMeta?.message,
|
|
timezone: override?.timezone || template.documentMeta?.timezone,
|
|
dateFormat: override?.dateFormat || template.documentMeta?.dateFormat,
|
|
redirectUrl: override?.redirectUrl || template.documentMeta?.redirectUrl,
|
|
distributionMethod: override?.distributionMethod || template.documentMeta?.distributionMethod,
|
|
emailSettings: override?.emailSettings || template.documentMeta?.emailSettings,
|
|
signingOrder: override?.signingOrder || template.documentMeta?.signingOrder,
|
|
language: override?.language || template.documentMeta?.language || settings.documentLanguage,
|
|
typedSignatureEnabled: override?.typedSignatureEnabled ?? template.documentMeta?.typedSignatureEnabled,
|
|
uploadSignatureEnabled: override?.uploadSignatureEnabled ?? template.documentMeta?.uploadSignatureEnabled,
|
|
drawSignatureEnabled: override?.drawSignatureEnabled ?? template.documentMeta?.drawSignatureEnabled,
|
|
allowDictateNextSigner: override?.allowDictateNextSigner ?? template.documentMeta?.allowDictateNextSigner,
|
|
envelopeExpirationPeriod: override?.envelopeExpirationPeriod ?? template.documentMeta?.envelopeExpirationPeriod,
|
|
},
|
|
signatureLevel,
|
|
),
|
|
});
|
|
|
|
const { envelope, createdEnvelope } = await prisma.$transaction(async (tx) => {
|
|
const envelope = await tx.envelope.create({
|
|
data: {
|
|
id: prefixedId('envelope'),
|
|
secondaryId: incrementedDocumentId.formattedDocumentId,
|
|
type: EnvelopeType.DOCUMENT,
|
|
internalVersion: template.internalVersion,
|
|
signatureLevel,
|
|
qrToken: prefixedId('qr'),
|
|
source: DocumentSource.TEMPLATE,
|
|
externalId: externalId || template.externalId,
|
|
templateId: legacyTemplateId, // The template this envelope was created from.
|
|
userId,
|
|
folderId,
|
|
teamId,
|
|
title: finalEnvelopeTitle,
|
|
envelopeItems: {
|
|
createMany: {
|
|
data: envelopeItemsToCreate,
|
|
},
|
|
},
|
|
authOptions: createDocumentAuthOptions({
|
|
globalAccessAuth: templateAuthOptions.globalAccessAuth,
|
|
globalActionAuth: templateAuthOptions.globalActionAuth,
|
|
}),
|
|
visibility: template.visibility || settings.documentVisibility,
|
|
useLegacyFieldInsertion: template.useLegacyFieldInsertion ?? false,
|
|
documentMetaId: documentMeta.id,
|
|
formValues: formValues ?? undefined,
|
|
recipients: {
|
|
createMany: {
|
|
data: allFinalRecipients.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: recipient.token,
|
|
};
|
|
}),
|
|
},
|
|
},
|
|
},
|
|
include: {
|
|
recipients: {
|
|
orderBy: {
|
|
id: 'asc',
|
|
},
|
|
},
|
|
envelopeItems: {
|
|
select: {
|
|
id: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
let fieldsToCreate: Omit<Field, 'id' | 'secondaryId'>[] = [];
|
|
|
|
// 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(allFinalRecipients).forEach(({ token, fields }) => {
|
|
const recipient = envelope.recipients.find((recipient) => recipient.token === token);
|
|
|
|
if (!recipient) {
|
|
throw new Error('Recipient not found.');
|
|
}
|
|
|
|
fieldsToCreate = fieldsToCreate.concat(
|
|
fields.map((field) => {
|
|
const prefillField = prefillFields?.find((value) => value.id === field.id);
|
|
|
|
const payload = {
|
|
envelopeItemId: oldEnvelopeItemToNewEnvelopeItemIdMap[field.envelopeItemId],
|
|
envelopeId: envelope.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.documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT,
|
|
);
|
|
|
|
payload.inserted = true;
|
|
})
|
|
.otherwise((selector) => {
|
|
payload.fieldMeta = getUpdatedFieldMeta(field, selector);
|
|
});
|
|
}
|
|
|
|
return payload;
|
|
}),
|
|
);
|
|
});
|
|
|
|
await tx.field.createMany({
|
|
data: fieldsToCreate.map((field) => ({
|
|
...field,
|
|
fieldMeta: field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined,
|
|
})),
|
|
});
|
|
|
|
await tx.documentAuditLog.create({
|
|
data: createDocumentAuditLogData({
|
|
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED,
|
|
envelopeId: envelope.id,
|
|
metadata: requestMetadata,
|
|
data: {
|
|
title: envelope.title,
|
|
source: {
|
|
type: DocumentSource.TEMPLATE,
|
|
templateId: legacyTemplateId,
|
|
},
|
|
},
|
|
}),
|
|
});
|
|
|
|
const templateAttachments = await tx.envelopeAttachment.findMany({
|
|
where: {
|
|
envelopeId: template.id,
|
|
},
|
|
});
|
|
|
|
const attachmentsToCreate = [
|
|
...templateAttachments.map((attachment) => ({
|
|
envelopeId: envelope.id,
|
|
type: attachment.type,
|
|
label: attachment.label,
|
|
data: attachment.data,
|
|
})),
|
|
...(attachments || []).map((attachment) => ({
|
|
envelopeId: envelope.id,
|
|
type: attachment.type || 'link',
|
|
label: attachment.label,
|
|
data: attachment.data,
|
|
})),
|
|
];
|
|
|
|
if (attachmentsToCreate.length > 0) {
|
|
await tx.envelopeAttachment.createMany({
|
|
data: attachmentsToCreate,
|
|
});
|
|
}
|
|
|
|
const createdEnvelope = await tx.envelope.findFirst({
|
|
where: {
|
|
id: envelope.id,
|
|
},
|
|
include: {
|
|
documentMeta: true,
|
|
recipients: true,
|
|
},
|
|
});
|
|
|
|
if (!createdEnvelope) {
|
|
throw new Error('Document not found');
|
|
}
|
|
|
|
return { envelope, createdEnvelope };
|
|
});
|
|
|
|
// Trigger webhook outside the transaction to avoid holding the connection
|
|
// open during network I/O.
|
|
await Promise.allSettled([
|
|
triggerWebhook({
|
|
event: WebhookTriggerEvents.DOCUMENT_CREATED,
|
|
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(createdEnvelope)),
|
|
userId,
|
|
teamId,
|
|
}),
|
|
triggerWebhook({
|
|
event: WebhookTriggerEvents.TEMPLATE_USED,
|
|
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(createdEnvelope)),
|
|
userId,
|
|
teamId,
|
|
}),
|
|
]);
|
|
|
|
return envelope;
|
|
};
|