mirror of
https://github.com/documenso/documenso.git
synced 2026-10-04 18:54:00 +10:00
<img width="1810" height="1408" alt="image" src="https://github.com/user-attachments/assets/1375ebf6-9aa5-4f02-9b21-8913f71d6634" />
209 lines
5.8 KiB
TypeScript
209 lines
5.8 KiB
TypeScript
import { prisma } from '@documenso/prisma';
|
|
import {
|
|
DocumentSigningOrder,
|
|
DocumentStatus,
|
|
EnvelopeType,
|
|
RecipientRole,
|
|
SendStatus,
|
|
SigningStatus,
|
|
} from '@prisma/client';
|
|
|
|
import { AppError, AppErrorCode } from '../../errors/app-error';
|
|
import { jobs } from '../../jobs/client';
|
|
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
|
import { isTspEnvelope } from '../../types/signature-level';
|
|
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
|
|
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
|
import { mapSecondaryIdToDocumentId } from '../../utils/envelope';
|
|
import { getRecipientsInActiveSigningStep, isRecipientTurnBySigningOrder } from '../../utils/recipient-groups';
|
|
import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable';
|
|
|
|
export type AdminDeleteRecipientOptions = {
|
|
recipientId: number;
|
|
reason: string;
|
|
requestMetadata: ApiRequestMetadata;
|
|
};
|
|
|
|
/**
|
|
* Unlike `deleteEnvelopeRecipient` this is not team scoped and allows removing
|
|
* recipients who have opened the document or inserted fields, as long as they
|
|
* have not signed.
|
|
*/
|
|
export const adminDeleteRecipient = async ({ recipientId, reason, requestMetadata }: AdminDeleteRecipientOptions) => {
|
|
const recipient = await prisma.recipient.findFirst({
|
|
where: {
|
|
id: recipientId,
|
|
},
|
|
include: {
|
|
envelope: {
|
|
select: {
|
|
id: true,
|
|
secondaryId: true,
|
|
type: true,
|
|
status: true,
|
|
signatureLevel: true,
|
|
completedAt: true,
|
|
userId: true,
|
|
documentMeta: {
|
|
select: {
|
|
signingOrder: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!recipient) {
|
|
throw new AppError(AppErrorCode.NOT_FOUND, {
|
|
message: 'Recipient not found',
|
|
});
|
|
}
|
|
|
|
const { envelope } = recipient;
|
|
|
|
if (envelope.type !== EnvelopeType.DOCUMENT) {
|
|
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
|
message: 'Recipient does not belong to a document',
|
|
});
|
|
}
|
|
|
|
if (envelope.completedAt) {
|
|
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
|
message: 'Document already complete',
|
|
});
|
|
}
|
|
|
|
if (recipient.signingStatus === SigningStatus.SIGNED) {
|
|
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
|
message: 'Recipient has already completed signing',
|
|
});
|
|
}
|
|
|
|
// The TSP (AES/QES) lock protects WYSIWYS and is not bypassed for admins.
|
|
await assertEnvelopeMutable(envelope);
|
|
|
|
const deletedRecipient = await prisma.$transaction(async (tx) => {
|
|
await assertEnvelopeMutable(envelope, tx);
|
|
|
|
await tx.documentAuditLog.create({
|
|
data: 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,
|
|
removedByAdmin: true,
|
|
reason,
|
|
},
|
|
}),
|
|
});
|
|
|
|
return await tx.recipient.delete({
|
|
where: {
|
|
id: recipientId,
|
|
},
|
|
});
|
|
});
|
|
|
|
const shouldNotifyRecipient = recipient.sendStatus === SendStatus.SENT && recipient.role !== RecipientRole.CC;
|
|
|
|
await jobs.triggerJob({
|
|
name: 'send.admin.recipient.removed.emails',
|
|
payload: {
|
|
envelopeId: envelope.id,
|
|
recipientEmail: recipient.email,
|
|
recipientName: recipient.name,
|
|
notifyRecipient: shouldNotifyRecipient,
|
|
},
|
|
});
|
|
|
|
// The removed recipient may have been the only thing blocking completion or
|
|
// the next sequential step, so seal or advance as the signing flow would.
|
|
if (envelope.status !== DocumentStatus.PENDING) {
|
|
return deletedRecipient;
|
|
}
|
|
|
|
const documentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
|
|
|
|
const remainingRecipients = await prisma.recipient.findMany({
|
|
where: {
|
|
envelopeId: envelope.id,
|
|
},
|
|
select: {
|
|
id: true,
|
|
role: true,
|
|
signingOrder: true,
|
|
signingStatus: true,
|
|
sendStatus: true,
|
|
},
|
|
orderBy: [{ signingOrder: { sort: 'asc', nulls: 'last' } }, { id: 'asc' }],
|
|
});
|
|
|
|
const signableRecipients = remainingRecipients.filter((recipient) => recipient.role !== RecipientRole.CC);
|
|
|
|
// A document left with no signers stays pending rather than sealing with no signatures.
|
|
const haveAllRecipientsSigned =
|
|
signableRecipients.length > 0 &&
|
|
signableRecipients.every((recipient) => recipient.signingStatus === SigningStatus.SIGNED);
|
|
|
|
if (haveAllRecipientsSigned) {
|
|
await jobs.triggerJob({
|
|
name: 'internal.seal-document',
|
|
payload: {
|
|
documentId,
|
|
requestMetadata: requestMetadata.requestMetadata,
|
|
},
|
|
});
|
|
|
|
return deletedRecipient;
|
|
}
|
|
|
|
if (envelope.documentMeta?.signingOrder !== DocumentSigningOrder.SEQUENTIAL) {
|
|
return deletedRecipient;
|
|
}
|
|
|
|
const recipientsToSend = getRecipientsInActiveSigningStep(remainingRecipients, {
|
|
strictlySequential: isTspEnvelope(envelope),
|
|
}).filter(
|
|
(recipient) =>
|
|
recipient.sendStatus !== SendStatus.SENT &&
|
|
isRecipientTurnBySigningOrder(remainingRecipients, recipient, { strictlySequential: isTspEnvelope(envelope) }),
|
|
);
|
|
|
|
if (recipientsToSend.length === 0) {
|
|
return deletedRecipient;
|
|
}
|
|
|
|
await prisma.recipient.updateMany({
|
|
where: {
|
|
id: {
|
|
in: recipientsToSend.map((recipient) => recipient.id),
|
|
},
|
|
},
|
|
data: {
|
|
sendStatus: SendStatus.SENT,
|
|
sentAt: new Date(),
|
|
},
|
|
});
|
|
|
|
await Promise.allSettled(
|
|
recipientsToSend.map(async (recipient) =>
|
|
jobs.triggerJob({
|
|
name: 'send.signing.requested.email',
|
|
payload: {
|
|
userId: envelope.userId,
|
|
documentId,
|
|
recipientId: recipient.id,
|
|
requestMetadata: requestMetadata.requestMetadata,
|
|
},
|
|
}),
|
|
),
|
|
);
|
|
|
|
return deletedRecipient;
|
|
};
|