import { materializeTspAnchorsForEnvelope } from '@documenso/ee/server-only/signing/csc/materialize-anchors'; import { resolveExpiresAt } from '@documenso/lib/constants/envelope-expiration'; import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs'; import { prisma } from '@documenso/prisma'; import { checkboxValidationSigns } from '@documenso/ui/primitives/document-flow/field-items-advanced-settings/constants'; import type { DocumentData, Envelope, EnvelopeItem, Field, Recipient } from '@prisma/client'; import { DocumentSigningOrder, DocumentStatus, EnvelopeType, FieldType, RecipientRole, SendStatus, SigningStatus, WebhookTriggerEvents, } from '@prisma/client'; import { validateCheckboxLength } from '../../advanced-fields-validation/validate-checkbox'; import { DIRECT_TEMPLATE_RECIPIENT_EMAIL } from '../../constants/direct-templates'; import { AppError, AppErrorCode } from '../../errors/app-error'; import { jobs } from '../../jobs/client'; import { extractDerivedDocumentEmailSettings } from '../../types/document-email'; import { ZCheckboxFieldMeta, ZDropdownFieldMeta, ZFieldAndMetaSchema, ZNumberFieldMeta, ZRadioFieldMeta, ZTextFieldMeta, } from '../../types/field-meta'; import { isTspEnvelope } from '../../types/signature-level'; import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../types/webhook-payload'; import { getFileServerSide } from '../../universal/upload/get-file.server'; import { putNormalizedPdfFileServerSide } from '../../universal/upload/put-file.server'; import { isDocumentCompleted } from '../../utils/document'; import { extractDocumentAuthMethods } from '../../utils/document-auth'; import { type EnvelopeIdOptions, mapSecondaryIdToDocumentId } from '../../utils/envelope'; import { toCheckboxCustomText, toRadioCustomText } from '../../utils/fields'; import { getRecipientsWithMissingFields, isRecipientEmailValidForSending } from '../../utils/recipients'; import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf'; import { assertUserNotDisabledById } from '../user/assert-user-not-disabled'; import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; export type SendDocumentOptions = { id: EnvelopeIdOptions; userId: number; teamId: number; sendEmail?: boolean; requestMetadata: ApiRequestMetadata; }; export const sendDocument = async ({ id, userId, teamId, sendEmail, requestMetadata }: SendDocumentOptions) => { // Refuse to send on behalf of a disabled account. Guards distribute / // redistribute / template-use routes, the bulk-send job, and direct // templates that auto-send on creation. await assertUserNotDisabledById({ userId }); const { envelopeWhereInput } = await getEnvelopeWhereInput({ id, type: EnvelopeType.DOCUMENT, userId, teamId, }); const envelope = await prisma.envelope.findFirst({ where: envelopeWhereInput, include: { recipients: { orderBy: [{ signingOrder: { sort: 'asc', nulls: 'last' } }, { id: 'asc' }], }, fields: true, documentMeta: true, envelopeItems: { select: { id: true, documentData: { select: { type: true, id: true, data: true, initialData: true, }, }, }, }, team: { select: { organisation: { select: { organisationClaim: { select: { recipientCount: true, }, }, }, }, }, }, }, }); if (!envelope) { throw new Error('Document not found'); } if (envelope.recipients.length === 0) { throw new Error('Document has no recipients'); } // A recipientCount of 0 means unlimited recipients are allowed. const maximumRecipientCount = envelope.team.organisation.organisationClaim.recipientCount; if (maximumRecipientCount > 0 && envelope.recipients.length > maximumRecipientCount) { throw new AppError('RECIPIENT_LIMIT_EXCEEDED', { message: `You cannot send a document with more than ${maximumRecipientCount} recipients`, statusCode: 400, }); } if (isDocumentCompleted(envelope.status)) { throw new Error('Can not send completed document'); } const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId); let signingOrder = envelope.documentMeta?.signingOrder || DocumentSigningOrder.PARALLEL; if (isTspEnvelope(envelope) && signingOrder === DocumentSigningOrder.PARALLEL && envelope.documentMeta) { console.warn( `[CSC] Coercing signingOrder=PARALLEL → SEQUENTIAL for ${envelope.signatureLevel} envelope ${envelope.id} at send time. The schema-layer guard should have caught this earlier.`, ); await prisma.documentMeta.update({ where: { id: envelope.documentMeta.id, }, data: { signingOrder: DocumentSigningOrder.SEQUENTIAL, }, }); signingOrder = DocumentSigningOrder.SEQUENTIAL; envelope.documentMeta.signingOrder = DocumentSigningOrder.SEQUENTIAL; } let recipientsToNotify = envelope.recipients; if (signingOrder === DocumentSigningOrder.SEQUENTIAL) { // Get the currently active recipient. recipientsToNotify = envelope.recipients .filter((r) => r.signingStatus === SigningStatus.NOT_SIGNED && r.role !== RecipientRole.CC) .slice(0, 1); } if (envelope.envelopeItems.length === 0) { throw new Error('Missing envelope items'); } if (envelope.formValues && envelope.status === DocumentStatus.DRAFT) { await Promise.all( envelope.envelopeItems.map(async (envelopeItem) => { await injectFormValuesIntoDocument(envelope, envelopeItem); }), ); } // Validate that recipients with auth requirements have a valid email. envelope.recipients.forEach((recipient) => { const auth = extractDocumentAuthMethods({ documentAuth: envelope.authOptions, recipientAuth: recipient.authOptions, }); if ( recipient.role !== RecipientRole.CC && (auth.recipientAccessAuthRequired || auth.recipientActionAuthRequired) && !isRecipientEmailValidForSending(recipient) ) { throw new AppError(AppErrorCode.INVALID_REQUEST, { message: `Recipient ${recipient.id} requires an email because they have auth requirements.`, }); } }); // Validate that recipients who require fields (e.g., signers need signature fields) have them. const recipientsWithMissingFields = getRecipientsWithMissingFields(envelope.recipients, envelope.fields); if (recipientsWithMissingFields.length > 0) { const missingRecipientDescriptions = recipientsWithMissingFields .map((r) => (r.name ? `${r.name} (${r.email}, id: ${r.id})` : `${r.email} (id: ${r.id})`)) .join(', '); throw new AppError(AppErrorCode.INVALID_REQUEST, { message: `The following recipients are missing required fields: ${missingRecipientDescriptions}. Signers must have at least one signature field.`, }); } const allRecipientsHaveNoActionToTake = envelope.recipients.every( (recipient) => recipient.role === RecipientRole.CC || recipient.signingStatus === SigningStatus.SIGNED, ); if (allRecipientsHaveNoActionToTake) { await jobs.triggerJob({ name: 'internal.seal-document', payload: { documentId: legacyDocumentId, requestMetadata: requestMetadata?.requestMetadata, }, }); // Keep the return type the same for the `sendDocument` method return await prisma.envelope.findFirstOrThrow({ where: { id: envelope.id, }, include: { documentMeta: true, recipients: true, }, }); } const fieldsToAutoInsert: { fieldId: number; customText: string }[] = []; // Validate and autoinsert fields for V2 envelopes. if (envelope.internalVersion === 2) { for (const unknownField of envelope.fields) { const recipient = envelope.recipients.find((r) => r.id === unknownField.recipientId); if (!recipient) { throw new AppError(AppErrorCode.NOT_FOUND, { message: 'Recipient not found', }); } const fieldToAutoInsert = extractFieldAutoInsertValues(unknownField, recipient); // Only auto-insert fields if the recipient has not been sent the document yet. if (fieldToAutoInsert && recipient.sendStatus !== SendStatus.SENT) { fieldsToAutoInsert.push(fieldToAutoInsert); } } } if (isTspEnvelope(envelope) && envelope.status === DocumentStatus.DRAFT) { await materializeTspAnchorsForEnvelope({ envelopeId: envelope.id, }); } const updatedEnvelope = await prisma.$transaction(async (tx) => { if (envelope.status === DocumentStatus.DRAFT) { await tx.documentAuditLog.create({ data: createDocumentAuditLogData({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT, envelopeId: envelope.id, metadata: requestMetadata, data: {}, }), }); } if (envelope.internalVersion === 2) { const autoInsertedFields = await Promise.all( fieldsToAutoInsert.map(async (field) => { // Warning: Only auto-insert fields if the recipient has not been sent the document yet. return await tx.field.update({ where: { id: field.fieldId, }, data: { customText: field.customText, inserted: true, }, }); }), ); await tx.documentAuditLog.create({ data: createDocumentAuditLogData({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELDS_AUTO_INSERTED, envelopeId: envelope.id, data: { fields: autoInsertedFields.map((field) => ({ fieldId: field.id, fieldType: field.type, recipientId: field.recipientId, })), }, // Don't put metadata or user here since it's a system event. }), }); } const expiresAt = resolveExpiresAt(envelope.documentMeta?.envelopeExpirationPeriod ?? null); // Set expiresAt on each recipient that hasn't already signed/rejected. // Exclude CC recipients since they don't sign and shouldn't be subject to expiry. if (expiresAt) { await tx.recipient.updateMany({ where: { envelopeId: envelope.id, signingStatus: { notIn: [SigningStatus.SIGNED, SigningStatus.REJECTED], }, role: { not: RecipientRole.CC, }, }, data: { expiresAt, expirationNotifiedAt: null, }, }); } return await tx.envelope.update({ where: { id: envelope.id, }, data: { status: DocumentStatus.PENDING, }, include: { documentMeta: true, recipients: true, }, }); }); const isRecipientSigningRequestEmailEnabled = extractDerivedDocumentEmailSettings( envelope.documentMeta, ).recipientSigningRequest; // Only send email if one of the following is true: // - It is explicitly set // - The email is enabled for signing requests AND sendEmail is undefined if (sendEmail || (isRecipientSigningRequestEmailEnabled && sendEmail === undefined)) { await Promise.all( recipientsToNotify.map(async (recipient) => { if (recipient.sendStatus === SendStatus.SENT || recipient.role === RecipientRole.CC) { return; } await jobs.triggerJob({ name: 'send.signing.requested.email', payload: { userId, documentId: legacyDocumentId, recipientId: recipient.id, requestMetadata: requestMetadata?.requestMetadata, }, }); }), ); } await triggerWebhook({ event: WebhookTriggerEvents.DOCUMENT_SENT, data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(updatedEnvelope)), userId, teamId, }); return updatedEnvelope; }; const injectFormValuesIntoDocument = async ( envelope: Envelope, envelopeItem: Pick & { documentData: DocumentData }, ) => { const file = await getFileServerSide(envelopeItem.documentData); const prefilled = await insertFormValuesInPdf({ pdf: Buffer.from(file), // eslint-disable-next-line @typescript-eslint/consistent-type-assertions formValues: envelope.formValues as Record, }); let fileName = envelope.title; if (!envelope.title.endsWith('.pdf')) { fileName = `${envelope.title}.pdf`; } const newDocumentData = await putNormalizedPdfFileServerSide({ name: fileName, type: 'application/pdf', arrayBuffer: async () => Promise.resolve(prefilled), }); await prisma.envelopeItem.update({ where: { id: envelopeItem.id, }, data: { documentDataId: newDocumentData.id, }, }); }; /** * Extracts the auto insertion values for a given field. * * If field is not auto insertable, returns `null`. */ export const extractFieldAutoInsertValues = ( unknownField: Field, recipient: Pick, ): { fieldId: number; customText: string } | null => { const parsedField = ZFieldAndMetaSchema.safeParse(unknownField); if (parsedField.error) { throw new AppError(AppErrorCode.INVALID_REQUEST, { message: 'One or more fields have invalid metadata. Error: ' + parsedField.error.message, }); } const field = parsedField.data; const fieldId = unknownField.id; // Auto insert email fields if the recipient has a valid email. if ( field.type === FieldType.EMAIL && isRecipientEmailValidForSending(recipient) && recipient.email !== DIRECT_TEMPLATE_RECIPIENT_EMAIL ) { return { fieldId, customText: recipient.email, }; } // Auto insert text fields with prefilled values. if (field.type === FieldType.TEXT) { const { text } = ZTextFieldMeta.parse(field.fieldMeta); if (text) { return { fieldId, customText: text, }; } } // Auto insert number fields with prefilled values. if (field.type === FieldType.NUMBER) { const { value } = ZNumberFieldMeta.parse(field.fieldMeta); if (value) { return { fieldId, customText: value, }; } } // Auto insert radio fields with the pre-checked value. if (field.type === FieldType.RADIO) { const { values = [] } = ZRadioFieldMeta.parse(field.fieldMeta); const checkedItemIndex = values.findIndex((value) => value.checked); if (checkedItemIndex !== -1) { return { fieldId, customText: toRadioCustomText(checkedItemIndex), }; } } // Auto insert dropdown fields with the default value. if (field.type === FieldType.DROPDOWN) { const { defaultValue, values = [] } = ZDropdownFieldMeta.parse(field.fieldMeta); if (defaultValue && values.some((value) => value.value === defaultValue)) { return { fieldId, customText: defaultValue, }; } } // Auto insert checkbox fields with the pre-checked values. if (field.type === FieldType.CHECKBOX) { const { values = [], validationRule, validationLength } = ZCheckboxFieldMeta.parse(field.fieldMeta); const checkedIndices: number[] = []; values.forEach((value, i) => { if (value.checked) { checkedIndices.push(i); } }); let isValid = true; if (validationRule && validationLength) { const validation = checkboxValidationSigns.find((sign) => sign.label === validationRule); if (!validation) { throw new AppError(AppErrorCode.INVALID_REQUEST, { message: 'Invalid checkbox validation rule', }); } isValid = validateCheckboxLength(checkedIndices.length, validation.value, validationLength); } if (isValid && checkedIndices.length > 0) { return { fieldId, customText: toCheckboxCustomText(checkedIndices), }; } } return null; };