mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 00:03:33 +10:00
This PR is handles the changes required to support envelopes. The new envelope editor/signing page will be hidden during release. The core changes here is to migrate the documents and templates model to a centralized envelopes model. Even though Documents and Templates are removed, from the user perspective they will still exist as we remap envelopes to documents and templates.
342 lines
9.7 KiB
TypeScript
342 lines
9.7 KiB
TypeScript
import {
|
|
DocumentSigningOrder,
|
|
DocumentStatus,
|
|
EnvelopeType,
|
|
RecipientRole,
|
|
SendStatus,
|
|
SigningStatus,
|
|
WebhookTriggerEvents,
|
|
} from '@prisma/client';
|
|
|
|
import {
|
|
DOCUMENT_AUDIT_LOG_TYPE,
|
|
RECIPIENT_DIFF_TYPE,
|
|
} from '@documenso/lib/types/document-audit-logs';
|
|
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
|
import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
|
|
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
|
import { prisma } from '@documenso/prisma';
|
|
|
|
import { AppError, AppErrorCode } from '../../errors/app-error';
|
|
import { jobs } from '../../jobs/client';
|
|
import type { TRecipientAccessAuth, TRecipientActionAuth } from '../../types/document-auth';
|
|
import { DocumentAuth } from '../../types/document-auth';
|
|
import {
|
|
ZWebhookDocumentSchema,
|
|
mapEnvelopeToWebhookDocumentPayload,
|
|
} from '../../types/webhook-payload';
|
|
import { extractDocumentAuthMethods } from '../../utils/document-auth';
|
|
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
|
import { mapSecondaryIdToDocumentId, unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
|
|
import { getIsRecipientsTurnToSign } from '../recipient/get-is-recipient-turn';
|
|
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
|
import { isRecipientAuthorized } from './is-recipient-authorized';
|
|
import { sendPendingEmail } from './send-pending-email';
|
|
|
|
export type CompleteDocumentWithTokenOptions = {
|
|
token: string;
|
|
id: EnvelopeIdOptions;
|
|
userId?: number;
|
|
authOptions?: TRecipientActionAuth;
|
|
accessAuthOptions?: TRecipientAccessAuth;
|
|
requestMetadata?: RequestMetadata;
|
|
nextSigner?: {
|
|
email: string;
|
|
name: string;
|
|
};
|
|
};
|
|
|
|
export const completeDocumentWithToken = async ({
|
|
token,
|
|
id,
|
|
userId,
|
|
accessAuthOptions,
|
|
requestMetadata,
|
|
nextSigner,
|
|
}: CompleteDocumentWithTokenOptions) => {
|
|
const envelope = await prisma.envelope.findFirstOrThrow({
|
|
where: {
|
|
...unsafeBuildEnvelopeIdQuery(id, EnvelopeType.DOCUMENT),
|
|
recipients: {
|
|
some: {
|
|
token,
|
|
},
|
|
},
|
|
},
|
|
include: {
|
|
documentMeta: true,
|
|
recipients: {
|
|
where: {
|
|
token,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
|
|
|
|
if (envelope.status !== DocumentStatus.PENDING) {
|
|
throw new Error(`Document ${envelope.id} must be pending`);
|
|
}
|
|
|
|
if (envelope.recipients.length === 0) {
|
|
throw new Error(`Document ${envelope.id} has no recipient with token ${token}`);
|
|
}
|
|
|
|
const [recipient] = envelope.recipients;
|
|
|
|
if (recipient.signingStatus === SigningStatus.SIGNED) {
|
|
throw new Error(`Recipient ${recipient.id} has already signed`);
|
|
}
|
|
|
|
if (recipient.signingStatus === SigningStatus.REJECTED) {
|
|
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
|
message: 'Recipient has already rejected the document',
|
|
statusCode: 400,
|
|
});
|
|
}
|
|
|
|
if (envelope.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL) {
|
|
const isRecipientsTurn = await getIsRecipientsTurnToSign({ token: recipient.token });
|
|
|
|
if (!isRecipientsTurn) {
|
|
throw new Error(
|
|
`Recipient ${recipient.id} attempted to complete the document before it was their turn`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const fields = await prisma.field.findMany({
|
|
where: {
|
|
envelopeId: envelope.id,
|
|
recipientId: recipient.id,
|
|
},
|
|
});
|
|
|
|
if (fieldsContainUnsignedRequiredField(fields)) {
|
|
throw new Error(`Recipient ${recipient.id} has unsigned fields`);
|
|
}
|
|
|
|
// Check ACCESS AUTH 2FA validation during document completion
|
|
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
|
documentAuth: envelope.authOptions,
|
|
recipientAuth: recipient.authOptions,
|
|
});
|
|
|
|
if (derivedRecipientAccessAuth.includes(DocumentAuth.TWO_FACTOR_AUTH)) {
|
|
if (!accessAuthOptions) {
|
|
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
|
message: 'Access authentication required',
|
|
});
|
|
}
|
|
|
|
const isValid = await isRecipientAuthorized({
|
|
type: 'ACCESS_2FA',
|
|
documentAuthOptions: envelope.authOptions,
|
|
recipient: recipient,
|
|
userId, // Can be undefined for non-account recipients
|
|
authOptions: accessAuthOptions,
|
|
});
|
|
|
|
if (!isValid) {
|
|
await prisma.documentAuditLog.create({
|
|
data: createDocumentAuditLogData({
|
|
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_FAILED,
|
|
envelopeId: envelope.id,
|
|
data: {
|
|
recipientId: recipient.id,
|
|
recipientName: recipient.name,
|
|
recipientEmail: recipient.email,
|
|
},
|
|
}),
|
|
});
|
|
|
|
throw new AppError(AppErrorCode.TWO_FACTOR_AUTH_FAILED, {
|
|
message: 'Invalid 2FA authentication',
|
|
});
|
|
}
|
|
|
|
await prisma.documentAuditLog.create({
|
|
data: createDocumentAuditLogData({
|
|
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_VALIDATED,
|
|
envelopeId: envelope.id,
|
|
data: {
|
|
recipientId: recipient.id,
|
|
recipientName: recipient.name,
|
|
recipientEmail: recipient.email,
|
|
},
|
|
}),
|
|
});
|
|
}
|
|
|
|
await prisma.$transaction(async (tx) => {
|
|
await tx.recipient.update({
|
|
where: {
|
|
id: recipient.id,
|
|
},
|
|
data: {
|
|
signingStatus: SigningStatus.SIGNED,
|
|
signedAt: new Date(),
|
|
},
|
|
});
|
|
|
|
const authOptions = extractDocumentAuthMethods({
|
|
documentAuth: envelope.authOptions,
|
|
recipientAuth: recipient.authOptions,
|
|
});
|
|
|
|
await tx.documentAuditLog.create({
|
|
data: createDocumentAuditLogData({
|
|
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
|
|
envelopeId: envelope.id,
|
|
user: {
|
|
name: recipient.name,
|
|
email: recipient.email,
|
|
},
|
|
requestMetadata,
|
|
data: {
|
|
recipientEmail: recipient.email,
|
|
recipientName: recipient.name,
|
|
recipientId: recipient.id,
|
|
recipientRole: recipient.role,
|
|
actionAuth: authOptions.derivedRecipientActionAuth,
|
|
},
|
|
}),
|
|
});
|
|
});
|
|
|
|
await jobs.triggerJob({
|
|
name: 'send.recipient.signed.email',
|
|
payload: {
|
|
documentId: legacyDocumentId,
|
|
recipientId: recipient.id,
|
|
},
|
|
});
|
|
|
|
const pendingRecipients = await prisma.recipient.findMany({
|
|
select: {
|
|
id: true,
|
|
signingOrder: true,
|
|
name: true,
|
|
email: true,
|
|
role: true,
|
|
},
|
|
where: {
|
|
envelopeId: envelope.id,
|
|
signingStatus: {
|
|
not: SigningStatus.SIGNED,
|
|
},
|
|
role: {
|
|
not: RecipientRole.CC,
|
|
},
|
|
},
|
|
// Composite sort so our next recipient is always the one with the lowest signing order or id
|
|
// if there is a tie.
|
|
orderBy: [{ signingOrder: { sort: 'asc', nulls: 'last' } }, { id: 'asc' }],
|
|
});
|
|
|
|
if (pendingRecipients.length > 0) {
|
|
await sendPendingEmail({ id, recipientId: recipient.id });
|
|
|
|
if (envelope.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL) {
|
|
const [nextRecipient] = pendingRecipients;
|
|
|
|
await prisma.$transaction(async (tx) => {
|
|
if (nextSigner && envelope.documentMeta?.allowDictateNextSigner) {
|
|
await tx.documentAuditLog.create({
|
|
data: createDocumentAuditLogData({
|
|
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
|
|
envelopeId: envelope.id,
|
|
user: {
|
|
name: recipient.name,
|
|
email: recipient.email,
|
|
},
|
|
requestMetadata,
|
|
data: {
|
|
recipientEmail: nextRecipient.email,
|
|
recipientName: nextRecipient.name,
|
|
recipientId: nextRecipient.id,
|
|
recipientRole: nextRecipient.role,
|
|
changes: [
|
|
{
|
|
type: RECIPIENT_DIFF_TYPE.NAME,
|
|
from: nextRecipient.name,
|
|
to: nextSigner.name,
|
|
},
|
|
{
|
|
type: RECIPIENT_DIFF_TYPE.EMAIL,
|
|
from: nextRecipient.email,
|
|
to: nextSigner.email,
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
});
|
|
}
|
|
|
|
await tx.recipient.update({
|
|
where: { id: nextRecipient.id },
|
|
data: {
|
|
sendStatus: SendStatus.SENT,
|
|
...(nextSigner && envelope.documentMeta?.allowDictateNextSigner
|
|
? {
|
|
name: nextSigner.name,
|
|
email: nextSigner.email,
|
|
}
|
|
: {}),
|
|
},
|
|
});
|
|
|
|
await jobs.triggerJob({
|
|
name: 'send.signing.requested.email',
|
|
payload: {
|
|
userId: envelope.userId,
|
|
documentId: legacyDocumentId,
|
|
recipientId: nextRecipient.id,
|
|
requestMetadata,
|
|
},
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
const haveAllRecipientsSigned = await prisma.envelope.findFirst({
|
|
where: {
|
|
id: envelope.id,
|
|
recipients: {
|
|
every: {
|
|
OR: [{ signingStatus: SigningStatus.SIGNED }, { role: RecipientRole.CC }],
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (haveAllRecipientsSigned) {
|
|
await jobs.triggerJob({
|
|
name: 'internal.seal-document',
|
|
payload: {
|
|
documentId: legacyDocumentId,
|
|
requestMetadata,
|
|
},
|
|
});
|
|
}
|
|
|
|
const updatedDocument = await prisma.envelope.findFirstOrThrow({
|
|
where: {
|
|
id: envelope.id,
|
|
type: EnvelopeType.DOCUMENT,
|
|
},
|
|
include: {
|
|
documentMeta: true,
|
|
recipients: true,
|
|
},
|
|
});
|
|
|
|
await triggerWebhook({
|
|
event: WebhookTriggerEvents.DOCUMENT_SIGNED,
|
|
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(updatedDocument)),
|
|
userId: updatedDocument.userId,
|
|
teamId: updatedDocument.teamId ?? undefined,
|
|
});
|
|
};
|