mirror of
https://github.com/documenso/documenso
synced 2026-04-21 21:37:18 +00:00
506 lines
14 KiB
TypeScript
506 lines
14 KiB
TypeScript
import {
|
|
DocumentSigningOrder,
|
|
DocumentStatus,
|
|
EnvelopeType,
|
|
FieldType,
|
|
RecipientRole,
|
|
SendStatus,
|
|
SigningStatus,
|
|
WebhookTriggerEvents,
|
|
} from '@prisma/client';
|
|
import { DateTime } from 'luxon';
|
|
|
|
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
|
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
|
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 } 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 { assertRecipientNotExpired } from '../../utils/recipients';
|
|
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;
|
|
accessAuthOptions?: TRecipientAccessAuth;
|
|
requestMetadata?: RequestMetadata;
|
|
nextSigner?: {
|
|
email: string;
|
|
name: string;
|
|
};
|
|
/**
|
|
* Override the recipient information. This will only work if the recipient
|
|
* does not have a name or email set.
|
|
*/
|
|
recipientOverride?: {
|
|
email?: string;
|
|
name?: string;
|
|
};
|
|
};
|
|
|
|
export const completeDocumentWithToken = async ({
|
|
token,
|
|
id,
|
|
userId,
|
|
accessAuthOptions,
|
|
requestMetadata,
|
|
nextSigner,
|
|
recipientOverride,
|
|
}: 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;
|
|
|
|
assertRecipientNotExpired(recipient);
|
|
|
|
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`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// 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',
|
|
});
|
|
}
|
|
|
|
if (!recipient.email.trim()) {
|
|
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
|
message: `Recipient ${recipient.id} requires an email because they have auth requirements.`,
|
|
});
|
|
}
|
|
|
|
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,
|
|
},
|
|
}),
|
|
});
|
|
}
|
|
|
|
let fields = await prisma.field.findMany({
|
|
where: {
|
|
envelopeId: envelope.id,
|
|
recipientId: recipient.id,
|
|
},
|
|
});
|
|
|
|
// This should be scoped to the current recipient.
|
|
const uninsertedDateFields = fields.filter(
|
|
(field) => field.type === FieldType.DATE && !field.inserted,
|
|
);
|
|
|
|
let recipientName = recipient.name;
|
|
let recipientEmail = recipient.email;
|
|
|
|
// Only trim the name if it's been derived.
|
|
if (!recipientName) {
|
|
recipientName = (
|
|
recipientOverride?.name ||
|
|
fields.find((field) => field.type === FieldType.NAME)?.customText ||
|
|
''
|
|
).trim();
|
|
}
|
|
|
|
// Only trim the email if it's been derived.
|
|
if (!recipient.email) {
|
|
recipientEmail = (
|
|
recipientOverride?.email ||
|
|
fields.find((field) => field.type === FieldType.EMAIL)?.customText ||
|
|
''
|
|
)
|
|
.trim()
|
|
.toLowerCase();
|
|
}
|
|
|
|
if (!recipientEmail) {
|
|
throw new AppError(AppErrorCode.INVALID_BODY, {
|
|
message: 'Recipient email is required',
|
|
});
|
|
}
|
|
|
|
// Auto-insert all un-inserted date fields for V2 envelopes at completion time.
|
|
if (envelope.internalVersion === 2 && uninsertedDateFields.length > 0) {
|
|
const formattedDate = DateTime.now()
|
|
.setZone(envelope.documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE)
|
|
.toFormat(envelope.documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT);
|
|
|
|
const newDateFieldValues = {
|
|
customText: formattedDate,
|
|
inserted: true,
|
|
};
|
|
|
|
await prisma.field.updateMany({
|
|
where: {
|
|
id: {
|
|
in: uninsertedDateFields.map((field) => field.id),
|
|
},
|
|
},
|
|
data: {
|
|
...newDateFieldValues,
|
|
},
|
|
});
|
|
|
|
// Create audit log entries for each auto-inserted date field.
|
|
await prisma.documentAuditLog.createMany({
|
|
data: uninsertedDateFields.map((field) =>
|
|
createDocumentAuditLogData({
|
|
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED,
|
|
envelopeId: envelope.id,
|
|
user: {
|
|
email: recipientEmail,
|
|
name: recipientName,
|
|
},
|
|
requestMetadata,
|
|
data: {
|
|
recipientEmail: recipientEmail,
|
|
recipientId: recipient.id,
|
|
recipientName: recipientName,
|
|
recipientRole: recipient.role,
|
|
fieldId: field.secondaryId,
|
|
field: {
|
|
type: FieldType.DATE,
|
|
data: formattedDate,
|
|
},
|
|
},
|
|
}),
|
|
),
|
|
});
|
|
|
|
// Update the local fields array so the subsequent validation check passes.
|
|
fields = fields.map((field) => {
|
|
if (field.type === FieldType.DATE && !field.inserted) {
|
|
return {
|
|
...field,
|
|
...newDateFieldValues,
|
|
};
|
|
}
|
|
|
|
return field;
|
|
});
|
|
}
|
|
|
|
if (fieldsContainUnsignedRequiredField(fields)) {
|
|
throw new Error(`Recipient ${recipient.id} has unsigned fields`);
|
|
}
|
|
|
|
await prisma.$transaction(async (tx) => {
|
|
await tx.recipient.update({
|
|
where: {
|
|
id: recipient.id,
|
|
},
|
|
data: {
|
|
signingStatus: SigningStatus.SIGNED,
|
|
signedAt: new Date(),
|
|
name: recipientName,
|
|
email: recipientEmail,
|
|
},
|
|
});
|
|
|
|
if (recipientEmail !== recipient.email || recipientName !== recipient.name) {
|
|
await tx.documentAuditLog.create({
|
|
data: createDocumentAuditLogData({
|
|
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
|
|
envelopeId: envelope.id,
|
|
user: {
|
|
name: recipientName,
|
|
email: recipientEmail,
|
|
},
|
|
requestMetadata,
|
|
data: {
|
|
recipientEmail: recipient.email,
|
|
recipientName: recipient.name,
|
|
recipientId: recipient.id,
|
|
recipientRole: recipient.role,
|
|
changes: [
|
|
{
|
|
type: RECIPIENT_DIFF_TYPE.NAME,
|
|
from: recipient.name,
|
|
to: recipientName,
|
|
},
|
|
{
|
|
type: RECIPIENT_DIFF_TYPE.EMAIL,
|
|
from: recipient.email,
|
|
to: recipientEmail,
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
});
|
|
}
|
|
|
|
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: recipientName,
|
|
email: recipientEmail,
|
|
},
|
|
requestMetadata,
|
|
data: {
|
|
recipientEmail: recipientEmail,
|
|
recipientName: recipientName,
|
|
recipientId: recipient.id,
|
|
recipientRole: recipient.role,
|
|
actionAuth: authOptions.derivedRecipientActionAuth,
|
|
},
|
|
}),
|
|
});
|
|
});
|
|
|
|
const envelopeWithRelations = await prisma.envelope.findUniqueOrThrow({
|
|
where: { id: envelope.id },
|
|
include: { documentMeta: true, recipients: true },
|
|
});
|
|
|
|
await triggerWebhook({
|
|
event: WebhookTriggerEvents.DOCUMENT_RECIPIENT_COMPLETED,
|
|
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelopeWithRelations)),
|
|
userId: envelope.userId,
|
|
teamId: envelope.teamId,
|
|
});
|
|
|
|
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: recipientName,
|
|
email: recipientEmail,
|
|
},
|
|
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,
|
|
sentAt: new Date(),
|
|
...(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,
|
|
});
|
|
};
|