import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import type { PlaceholderInfo } from '@documenso/lib/server-only/pdf/auto-place-fields'; import { convertPlaceholdersToFieldInputs } from '@documenso/lib/server-only/pdf/auto-place-fields'; import { findRecipientByPlaceholder } from '@documenso/lib/server-only/pdf/helpers'; import { normalizePdf as makeNormalizedPdf } from '@documenso/lib/server-only/pdf/normalize-pdf'; import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients'; import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { nanoid, prefixedId } from '@documenso/lib/universal/id'; import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs'; import { prisma } from '@documenso/prisma'; import type { DocumentMeta, DocumentVisibility, TemplateType } from '@prisma/client'; import { DocumentSource, EnvelopeType, FolderType, RecipientRole, SendStatus, SigningStatus, WebhookTriggerEvents, } from '@prisma/client'; import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes, TRecipientAccessAuthTypes, TRecipientActionAuthTypes, } from '../../types/document-auth'; import type { TDocumentFormValues } from '../../types/document-form-values'; import type { TEnvelopeAttachmentType } from '../../types/envelope-attachment'; import type { TFieldAndMeta } from '../../types/field-meta'; import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../types/webhook-payload'; import { getFileServerSide } from '../../universal/upload/get-file.server'; import { putPdfFileServerSide } from '../../universal/upload/put-file.server'; import { extractDerivedDocumentMeta } from '../../utils/document'; import { createDocumentAuthOptions, createRecipientAuthOptions } from '../../utils/document-auth'; import { buildTeamWhereQuery } from '../../utils/teams'; import { incrementDocumentId, incrementTemplateId } from '../envelope/increment-id'; import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits'; import { getTeamSettings } from '../team/get-team-settings'; import { assertUserNotDisabledById } from '../user/assert-user-not-disabled'; import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; type CreateEnvelopeRecipientFieldOptions = TFieldAndMeta & { documentDataId: string; page: number; positionX: number; positionY: number; width: number; height: number; }; type CreateEnvelopeRecipientOptions = { email: string; name: string; role: RecipientRole; signingOrder?: number; accessAuth?: TRecipientAccessAuthTypes[]; actionAuth?: TRecipientActionAuthTypes[]; fields?: CreateEnvelopeRecipientFieldOptions[]; }; export type CreateEnvelopeOptions = { userId: number; teamId: number; normalizePdf?: boolean; internalVersion: 1 | 2; data: { type: EnvelopeType; title: string; externalId?: string; envelopeItems: { title?: string; documentDataId: string; order?: number; placeholders?: PlaceholderInfo[]; }[]; formValues?: TDocumentFormValues; userTimezone?: string; templateType?: TemplateType; publicTitle?: string; publicDescription?: string; visibility?: DocumentVisibility; globalAccessAuth?: TDocumentAccessAuthTypes[]; globalActionAuth?: TDocumentActionAuthTypes[]; recipients?: CreateEnvelopeRecipientOptions[]; folderId?: string; delegatedDocumentOwner?: string; }; attachments?: Array<{ label: string; data: string; type?: TEnvelopeAttachmentType; }>; /** * Whether to bypass adding default recipients. * * Defaults to false. */ bypassDefaultRecipients?: boolean; meta?: Partial>; requestMetadata: ApiRequestMetadata; }; export const createEnvelope = async ({ userId, teamId, normalizePdf, data, attachments, meta, requestMetadata, internalVersion, bypassDefaultRecipients = false, }: CreateEnvelopeOptions) => { // Refuse to create on behalf of a disabled account. Guards every route that // funnels through here (document.create, envelope.use, template create, // embedding template/document create, API v1) and the seed/job paths. await assertUserNotDisabledById({ userId }); const { type, title, externalId, formValues, userTimezone, folderId, templateType, globalAccessAuth, globalActionAuth, publicTitle, publicDescription, visibility: visibilityOverride, delegatedDocumentOwner, } = data; const team = await prisma.team.findFirst({ where: buildTeamWhereQuery({ teamId, userId }), include: { organisation: { select: { organisationClaim: true, }, }, }, }); if (!team) { throw new AppError(AppErrorCode.NOT_FOUND, { message: 'Team not found', }); } // Enforce the organisation document-creation limit before doing any work. // Only documents count towards the limit (templates are exempt). if (type === EnvelopeType.DOCUMENT) { await assertOrganisationRatesAndLimits({ organisationId: team.organisationId, organisationClaim: team.organisation.organisationClaim, type: 'document', count: 1, }); } // Verify that the folder exists and is associated with the team. if (folderId) { const folder = await prisma.folder.findUnique({ where: { id: folderId, type: data.type === EnvelopeType.TEMPLATE ? FolderType.TEMPLATE : FolderType.DOCUMENT, team: buildTeamWhereQuery({ teamId, userId }), }, }); if (!folder) { throw new AppError(AppErrorCode.NOT_FOUND, { message: 'Folder not found', }); } } const settings = await getTeamSettings({ userId, teamId, }); if (data.envelopeItems.length !== 1 && internalVersion === 1) { throw new AppError(AppErrorCode.INVALID_BODY, { message: 'Envelope items must have exactly 1 item for version 1', }); } let envelopeItems = data.envelopeItems; // Todo: Envelopes - Remove if (normalizePdf) { envelopeItems = await Promise.all( data.envelopeItems.map(async (item) => { const documentData = await prisma.documentData.findFirst({ where: { id: item.documentDataId, }, }); if (!documentData) { throw new AppError(AppErrorCode.NOT_FOUND, { message: 'Document data not found', }); } const buffer = await getFileServerSide(documentData); const normalizedPdf = await makeNormalizedPdf(Buffer.from(buffer), { flattenForm: type !== EnvelopeType.TEMPLATE, }); const titleToUse = item.title || title; const { documentData: newDocumentData } = await putPdfFileServerSide({ name: titleToUse, type: 'application/pdf', arrayBuffer: async () => Promise.resolve(normalizedPdf), }); return { title: titleToUse.endsWith('.pdf') ? titleToUse.slice(0, -4) : titleToUse, documentDataId: newDocumentData.id, order: item.order, }; }), ); } const authOptions = createDocumentAuthOptions({ globalAccessAuth: globalAccessAuth || [], globalActionAuth: globalActionAuth || [], }); const recipientsHaveActionAuth = data.recipients?.some( (recipient) => recipient.actionAuth && recipient.actionAuth.length > 0, ); // Check if user has permission to set the global action auth. if ( (authOptions.globalActionAuth.length > 0 || recipientsHaveActionAuth) && !team.organisation.organisationClaim.flags.cfr21 ) { throw new AppError(AppErrorCode.UNAUTHORIZED, { message: 'You do not have permission to set the action auth', }); } const visibility = visibilityOverride || settings.documentVisibility; const emailId = meta?.emailId; // Validate that the email ID belongs to the organisation. if (emailId) { const email = await prisma.organisationEmail.findFirst({ where: { id: emailId, organisationId: team.organisationId, }, }); if (!email) { throw new AppError(AppErrorCode.NOT_FOUND, { message: 'Email not found', }); } } // userTimezone is last because it's always passed in regardless of the organisation/team settings // for uploads from the frontend const timezoneToUse = meta?.timezone || settings.documentTimezone || userTimezone; const getValidatedDelegatedOwner = async () => { if (!settings.delegateDocumentOwnership || !delegatedDocumentOwner || requestMetadata.source === 'app') { return null; } const delegatedOwner = await prisma.user.findFirst({ where: { email: delegatedDocumentOwner, }, }); if (!delegatedOwner) { throw new AppError(AppErrorCode.UNAUTHORIZED, { message: 'Delegated document owner must be a member of the team', }); } const isTeamMember = await prisma.team.findFirst({ where: buildTeamWhereQuery({ teamId, userId: delegatedOwner.id }), }); if (!isTeamMember) { throw new AppError(AppErrorCode.UNAUTHORIZED, { message: 'Delegated document owner must be a member of the team', }); } return delegatedOwner; }; const [documentMeta, secondaryId, delegatedOwner] = await Promise.all([ prisma.documentMeta.create({ data: extractDerivedDocumentMeta(settings, { ...meta, timezone: timezoneToUse, }), }), type === EnvelopeType.DOCUMENT ? incrementDocumentId().then((v) => v.formattedDocumentId) : incrementTemplateId().then((v) => v.formattedTemplateId), getValidatedDelegatedOwner(), ]); const envelopeOwnerId = delegatedOwner?.id ?? userId; const createdEnvelope = await prisma.$transaction(async (tx) => { const envelope = await tx.envelope.create({ data: { id: prefixedId('envelope'), secondaryId, internalVersion, type, title, qrToken: prefixedId('qr'), externalId, envelopeItems: { createMany: { data: envelopeItems.map((item, i) => ({ id: prefixedId('envelope_item'), title: item.title || title, order: item.order !== undefined ? item.order : i + 1, documentDataId: item.documentDataId, })), }, }, envelopeAttachments: { createMany: { data: (attachments || []).map((attachment) => ({ label: attachment.label, data: attachment.data, type: attachment.type ?? 'link', })), }, }, userId: envelopeOwnerId, teamId, authOptions, visibility, folderId, formValues, source: type === EnvelopeType.DOCUMENT ? DocumentSource.DOCUMENT : DocumentSource.TEMPLATE, documentMetaId: documentMeta.id, // Template specific fields. templateType: type === EnvelopeType.TEMPLATE ? templateType : undefined, publicTitle: type === EnvelopeType.TEMPLATE ? publicTitle : undefined, publicDescription: type === EnvelopeType.TEMPLATE ? publicDescription : undefined, }, include: { envelopeItems: true, }, }); const firstEnvelopeItem = envelope.envelopeItems[0]; const defaultRecipients = settings.defaultRecipients && !bypassDefaultRecipients ? ZDefaultRecipientsSchema.parse(settings.defaultRecipients) : []; const mappedDefaultRecipients: CreateEnvelopeRecipientOptions[] = defaultRecipients.map((recipient) => ({ email: recipient.email, name: recipient.name, role: recipient.role, })); const allRecipients = [...(data.recipients || []), ...mappedDefaultRecipients]; await Promise.all( allRecipients.map(async (recipient) => { const recipientAuthOptions = createRecipientAuthOptions({ accessAuth: recipient.accessAuth ?? [], actionAuth: recipient.actionAuth ?? [], }); const recipientFieldsToCreate = (recipient.fields || []).map((field) => { let envelopeItemId = firstEnvelopeItem.id; if (field.documentDataId) { const foundEnvelopeItem = envelope.envelopeItems.find( (item) => item.documentDataId === field.documentDataId, ); if (!foundEnvelopeItem) { throw new AppError(AppErrorCode.NOT_FOUND, { message: 'Document data not found', }); } envelopeItemId = foundEnvelopeItem.id; } return { envelopeId: envelope.id, envelopeItemId, type: field.type, page: field.page, positionX: field.positionX, positionY: field.positionY, width: field.width, height: field.height, customText: '', inserted: false, fieldMeta: field.fieldMeta || undefined, }; }); await tx.recipient.create({ data: { envelopeId: envelope.id, name: recipient.name, email: recipient.email, role: recipient.role, signingOrder: recipient.signingOrder, token: nanoid(), sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT, signingStatus: recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED, authOptions: recipientAuthOptions, fields: { createMany: { data: recipientFieldsToCreate, }, }, }, }); }), ); // Create fields from PDF placeholders (extracted at upload time). const itemsWithPlaceholders = envelopeItems.filter((item) => item.placeholders && item.placeholders.length > 0); if (itemsWithPlaceholders.length > 0) { // Collect all unique recipient placeholder references (e.g. "r1", "r2"). const allPlaceholders = itemsWithPlaceholders.flatMap((item) => item.placeholders ?? []); const uniqueRecipientRefs = new Map(); for (const p of allPlaceholders) { const match = p.recipient.match(/^r(\d+)$/i); if (match) { const index = Number(match[1]); if (!uniqueRecipientRefs.has(index)) { uniqueRecipientRefs.set(index, `Recipient ${index}`); } } } // Fetch existing recipients (may have been created above from data.recipients or defaults). let availableRecipients = await tx.recipient.findMany({ where: { envelopeId: envelope.id }, select: { id: true, email: true }, }); const shouldCreatePlaceholderRecipients = (!data.recipients || data.recipients.length === 0) && uniqueRecipientRefs.size > 0; // If recipients were not provided, create placeholder recipients even when defaults exist. if (shouldCreatePlaceholderRecipients) { const existingRecipientEmails = new Set(availableRecipients.map((recipient) => recipient.email.toLowerCase())); const placeholderRecipients = Array.from(uniqueRecipientRefs.entries(), ([recipientIndex, name]) => ({ envelopeId: envelope.id, email: `recipient.${recipientIndex}@documenso.com`, name, role: RecipientRole.SIGNER, signingOrder: recipientIndex, token: nanoid(), sendStatus: SendStatus.NOT_SENT, signingStatus: SigningStatus.NOT_SIGNED, })).filter((recipient) => !existingRecipientEmails.has(recipient.email.toLowerCase())); if (placeholderRecipients.length > 0) { await tx.recipient.createMany({ data: placeholderRecipients, }); // eslint-disable-next-line require-atomic-updates availableRecipients = await tx.recipient.findMany({ where: { envelopeId: envelope.id }, select: { id: true, email: true }, }); } } for (const item of itemsWithPlaceholders) { const envelopeItem = envelope.envelopeItems.find((ei) => ei.documentDataId === item.documentDataId); if (!envelopeItem) { continue; } const fieldsToCreate = convertPlaceholdersToFieldInputs( item.placeholders ?? [], (recipientPlaceholder, placeholder) => findRecipientByPlaceholder( recipientPlaceholder, placeholder, data.recipients && data.recipients.length > 0 ? data.recipients.map((r) => { const found = availableRecipients.find((cr) => cr.email === r.email); if (!found) { throw new AppError(AppErrorCode.NOT_FOUND, { message: `Recipient not found for email: ${r.email}`, }); } return found; }) : undefined, availableRecipients, ), envelopeItem.id, ); if (fieldsToCreate.length > 0) { await tx.field.createMany({ data: fieldsToCreate.map((field) => ({ envelopeId: envelope.id, envelopeItemId: envelopeItem.id, recipientId: field.recipientId, type: field.type, page: field.page, positionX: field.positionX, positionY: field.positionY, width: field.width, height: field.height, customText: '', inserted: false, fieldMeta: field.fieldMeta || undefined, })), }); } } } const createdEnvelope = await tx.envelope.findFirst({ where: { id: envelope.id, }, include: { documentMeta: true, recipients: true, fields: true, folder: true, envelopeAttachments: true, envelopeItems: { include: { documentData: true, }, }, }, }); if (!createdEnvelope) { throw new AppError(AppErrorCode.NOT_FOUND, { message: 'Envelope not found', }); } // Only create audit logs for documents. if (type === EnvelopeType.DOCUMENT) { await tx.documentAuditLog.create({ data: createDocumentAuditLogData({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED, envelopeId: envelope.id, user: { id: envelopeOwnerId, }, metadata: requestMetadata, data: { title, source: { type: DocumentSource.DOCUMENT, }, }, }), }); // Create audit log for delegated owner if validation passed if (delegatedOwner) { await tx.documentAuditLog.create({ data: createDocumentAuditLogData({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELEGATED_OWNER_CREATED, envelopeId: envelope.id, user: { id: userId, }, metadata: requestMetadata, data: { delegatedOwnerName: delegatedOwner.name, delegatedOwnerEmail: delegatedOwner.email, teamName: team.name, }, }), }); } } return createdEnvelope; }); // Trigger webhook outside the transaction to avoid holding the connection // open during network I/O. if (type === EnvelopeType.DOCUMENT) { await triggerWebhook({ event: WebhookTriggerEvents.DOCUMENT_CREATED, data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(createdEnvelope)), userId, teamId, }); } else if (type === EnvelopeType.TEMPLATE) { await triggerWebhook({ event: WebhookTriggerEvents.TEMPLATE_CREATED, data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(createdEnvelope)), userId, teamId, }); } return createdEnvelope; };