import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; import type { TRecipientAccessAuthTypes } from '@documenso/lib/types/document-auth'; import { type TRecipientActionAuthTypes, ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth'; import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { nanoid } from '@documenso/lib/universal/id'; import { createDocumentAuditLogData, diffRecipientChanges } from '@documenso/lib/utils/document-audit-logs'; import { createRecipientAuthOptions } from '@documenso/lib/utils/document-auth'; import { prisma } from '@documenso/prisma'; import type { Recipient } from '@prisma/client'; import { EnvelopeType, RecipientRole, SendStatus, SigningStatus } from '@prisma/client'; import { isDeepEqual } from 'remeda'; import { AppError, AppErrorCode } from '../../errors/app-error'; import { jobs } from '../../jobs/client'; import { extractDerivedDocumentEmailSettings } from '../../types/document-email'; import { type EnvelopeIdOptions, mapSecondaryIdToDocumentId } from '../../utils/envelope'; import { canRecipientBeModified, isRecipientEmailValidForSending } from '../../utils/recipients'; import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable'; import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; import { assertCompatibleRecipientRole } from '../signature-level/assert-compatible-recipient-role'; export interface SetDocumentRecipientsOptions { userId: number; teamId: number; id: EnvelopeIdOptions; recipients: RecipientData[]; requestMetadata: ApiRequestMetadata; } export const setDocumentRecipients = async ({ userId, teamId, id, recipients, requestMetadata, }: SetDocumentRecipientsOptions) => { const { envelopeWhereInput } = await getEnvelopeWhereInput({ id, type: EnvelopeType.DOCUMENT, userId, teamId, }); const envelope = await prisma.envelope.findFirst({ where: envelopeWhereInput, include: { fields: true, documentMeta: true, team: { select: { organisation: { select: { organisationClaim: true, }, }, }, }, recipients: true, }, }); const user = await prisma.user.findFirstOrThrow({ where: { id: userId, }, select: { id: true, name: true, email: true, }, }); if (!envelope) { throw new Error('Document not found'); } assertEnvelopeMutable(envelope); if (envelope.completedAt) { throw new Error('Document already complete'); } const recipientsHaveActionAuth = recipients.some( (recipient) => recipient.actionAuth && recipient.actionAuth.length > 0, ); // Check if user has permission to set the global action auth. if (recipientsHaveActionAuth && !envelope.team.organisation.organisationClaim.flags.cfr21) { throw new AppError(AppErrorCode.UNAUTHORIZED, { message: 'You do not have permission to set the action auth', }); } for (const recipient of recipients) { assertCompatibleRecipientRole({ signatureLevel: envelope.signatureLevel, role: recipient.role, }); } const normalizedRecipients = recipients.map((recipient) => ({ ...recipient, email: recipient.email.toLowerCase(), })); const existingRecipients = envelope.recipients; const removedRecipients = existingRecipients.filter( (existingRecipient) => !normalizedRecipients.find((recipient) => recipient.id === existingRecipient.id), ); const linkedRecipients = normalizedRecipients.map((recipient) => { const existing = existingRecipients.find((existingRecipient) => existingRecipient.id === recipient.id); const canPersistedRecipientBeModified = existing && canRecipientBeModified(existing, envelope.fields); if ( existing && hasRecipientBeenChanged(existing, recipient) && !canRecipientBeModified(existing, envelope.fields) ) { throw new AppError(AppErrorCode.INVALID_REQUEST, { message: 'Cannot modify a recipient who has already interacted with the document', }); } return { ...recipient, _persisted: existing, canPersistedRecipientBeModified, }; }); const persistedRecipients = await prisma.$transaction(async (tx) => { await assertEnvelopeMutable(envelope, tx); return await Promise.all( linkedRecipients.map(async (recipient) => { let authOptions = ZRecipientAuthOptionsSchema.parse(recipient._persisted?.authOptions); if (recipient.actionAuth !== undefined || recipient.accessAuth !== undefined) { authOptions = createRecipientAuthOptions({ accessAuth: recipient.accessAuth || authOptions.accessAuth, actionAuth: recipient.actionAuth || authOptions.actionAuth, }); } if (recipient._persisted && !recipient.canPersistedRecipientBeModified) { return { ...recipient._persisted, clientId: recipient.clientId, }; } const upsertedRecipient = await tx.recipient.upsert({ where: { id: recipient._persisted?.id ?? -1, envelopeId: envelope.id, }, update: { name: recipient.name, email: recipient.email, role: recipient.role, signingOrder: recipient.signingOrder, envelopeId: envelope.id, sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT, signingStatus: recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED, authOptions, }, create: { name: recipient.name, email: recipient.email, role: recipient.role, signingOrder: recipient.signingOrder, token: nanoid(), envelopeId: envelope.id, sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT, signingStatus: recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED, authOptions, }, }); const recipientId = upsertedRecipient.id; // Clear all fields if the recipient role is changed to a type that cannot have fields. if ( recipient._persisted && recipient._persisted.role !== recipient.role && (recipient.role === RecipientRole.CC || recipient.role === RecipientRole.VIEWER) ) { await tx.field.deleteMany({ where: { recipientId, }, }); } const baseAuditLog = { recipientEmail: upsertedRecipient.email, recipientName: upsertedRecipient.name, recipientId, recipientRole: upsertedRecipient.role, }; const changes = recipient._persisted ? diffRecipientChanges(recipient._persisted, upsertedRecipient) : []; // Handle recipient updated audit log. if (recipient._persisted && changes.length > 0) { await tx.documentAuditLog.create({ data: createDocumentAuditLogData({ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED, envelopeId: envelope.id, metadata: requestMetadata, data: { changes, ...baseAuditLog, }, }), }); } // Handle recipient created audit log. if (!recipient._persisted) { await tx.documentAuditLog.create({ data: createDocumentAuditLogData({ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED, envelopeId: envelope.id, metadata: requestMetadata, data: { ...baseAuditLog, accessAuth: recipient.accessAuth || [], actionAuth: recipient.actionAuth || [], }, }), }); } return { ...upsertedRecipient, clientId: recipient.clientId, }; }), ); }); if (removedRecipients.length > 0) { await prisma.$transaction(async (tx) => { await tx.recipient.deleteMany({ where: { id: { in: removedRecipients.map((recipient) => recipient.id), }, }, }); await tx.documentAuditLog.createMany({ data: removedRecipients.map((recipient) => createDocumentAuditLogData({ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_DELETED, envelopeId: envelope.id, metadata: requestMetadata, data: { recipientEmail: recipient.email, recipientName: recipient.name, recipientId: recipient.id, recipientRole: recipient.role, }, }), ), }); }); const isRecipientRemovedEmailEnabled = extractDerivedDocumentEmailSettings(envelope.documentMeta).recipientRemoved; if (isRecipientRemovedEmailEnabled) { await Promise.all( removedRecipients.map(async (recipient) => { if ( recipient.sendStatus !== SendStatus.SENT || recipient.role === RecipientRole.CC || !isRecipientEmailValidForSending(recipient) ) { return; } await jobs.triggerJob({ name: 'send.recipient.removed.email', payload: { envelopeId: envelope.id, recipientEmail: recipient.email, recipientName: recipient.name, inviterName: user.name || undefined, }, }); }), ); } } // Filter out recipients that have been removed or have been updated. const filteredRecipients: RecipientDataWithClientId[] = existingRecipients.filter((recipient) => { const isRemoved = removedRecipients.find((removedRecipient) => removedRecipient.id === recipient.id); const isUpdated = persistedRecipients.find((persistedRecipient) => persistedRecipient.id === recipient.id); return !isRemoved && !isUpdated; }); return { recipients: [...filteredRecipients, ...persistedRecipients].map((recipient) => ({ ...recipient, documentId: mapSecondaryIdToDocumentId(envelope.secondaryId), templateId: null, })), }; }; /** * If you change this you MUST update the `hasRecipientBeenChanged` function. */ type RecipientData = { id?: number | null; clientId?: string | null; email: string; name: string; role: RecipientRole; signingOrder?: number | null; accessAuth?: TRecipientAccessAuthTypes[]; actionAuth?: TRecipientActionAuthTypes[]; }; type RecipientDataWithClientId = Recipient & { clientId?: string | null; }; const hasRecipientBeenChanged = (recipient: Recipient, newRecipientData: RecipientData) => { const authOptions = ZRecipientAuthOptionsSchema.parse(recipient.authOptions); const newRecipientAccessAuth = newRecipientData.accessAuth || []; const newRecipientActionAuth = newRecipientData.actionAuth || []; return ( recipient.email !== newRecipientData.email || recipient.name !== newRecipientData.name || recipient.role !== newRecipientData.role || recipient.signingOrder !== newRecipientData.signingOrder || !isDeepEqual(authOptions.accessAuth, newRecipientAccessAuth) || !isDeepEqual(authOptions.actionAuth, newRecipientActionAuth) ); };