mirror of
https://github.com/documenso/documenso.git
synced 2026-08-20 05:21:51 +10:00
555 lines
17 KiB
TypeScript
555 lines
17 KiB
TypeScript
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 {
|
|
DocumentSigningOrder,
|
|
DocumentStatus,
|
|
EnvelopeType,
|
|
FieldType,
|
|
RecipientRole,
|
|
SendStatus,
|
|
SigningStatus,
|
|
WebhookTriggerEvents,
|
|
} from '@prisma/client';
|
|
import { DateTime } from 'luxon';
|
|
|
|
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 { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } 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';
|
|
|
|
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.findFirst({
|
|
where: {
|
|
...unsafeBuildEnvelopeIdQuery(id, EnvelopeType.DOCUMENT),
|
|
recipients: {
|
|
some: {
|
|
token,
|
|
},
|
|
},
|
|
},
|
|
include: {
|
|
documentMeta: true,
|
|
recipients: {
|
|
where: {
|
|
token,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
// The most common cause is a stale signing page: the document was deleted,
|
|
// or the recipient was removed, after the link was opened. Surface a
|
|
// NOT_FOUND instead of leaking a Prisma P2025 as a 500.
|
|
if (!envelope) {
|
|
throw new AppError(AppErrorCode.NOT_FOUND, {
|
|
message: 'Document not found for the provided signing token',
|
|
statusCode: 404,
|
|
});
|
|
}
|
|
|
|
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
|
|
|
|
if (envelope.recipients.length === 0) {
|
|
throw new AppError(AppErrorCode.NOT_FOUND, {
|
|
message: `Document ${envelope.id} has no recipient with the provided token`,
|
|
statusCode: 404,
|
|
});
|
|
}
|
|
|
|
const [recipient] = envelope.recipients;
|
|
|
|
// A retried or duplicate completion request for an already signed
|
|
// recipient throws a code the router resolves idempotently. This must be
|
|
// checked before the envelope status guard since the envelope may have
|
|
// been completed and sealed by the recipient's original request.
|
|
if (recipient.signingStatus === SigningStatus.SIGNED) {
|
|
throw new AppError(AppErrorCode.RECIPIENT_ALREADY_SIGNED, {
|
|
message: `Recipient ${recipient.id} has already signed`,
|
|
statusCode: 400,
|
|
});
|
|
}
|
|
|
|
if (envelope.status !== DocumentStatus.PENDING) {
|
|
const envelopeStatusErrorCode: Record<DocumentStatus, AppErrorCode> = {
|
|
[DocumentStatus.DRAFT]: AppErrorCode.ENVELOPE_DRAFT,
|
|
[DocumentStatus.COMPLETED]: AppErrorCode.ENVELOPE_COMPLETED,
|
|
[DocumentStatus.REJECTED]: AppErrorCode.ENVELOPE_REJECTED,
|
|
[DocumentStatus.CANCELLED]: AppErrorCode.ENVELOPE_CANCELLED,
|
|
// Unreachable: guarded by the status check above.
|
|
[DocumentStatus.PENDING]: AppErrorCode.INVALID_REQUEST,
|
|
};
|
|
|
|
throw new AppError(envelopeStatusErrorCode[envelope.status], {
|
|
message: `Document ${envelope.id} must be pending to be completed, found ${envelope.status}`,
|
|
statusCode: 400,
|
|
});
|
|
}
|
|
|
|
assertRecipientNotExpired(recipient);
|
|
|
|
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 AppError(AppErrorCode.RECIPIENT_OUT_OF_TURN, {
|
|
message: `Recipient ${recipient.id} attempted to complete the document before it was their turn`,
|
|
statusCode: 400,
|
|
});
|
|
}
|
|
}
|
|
|
|
// 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 AppError(AppErrorCode.RECIPIENT_HAS_UNSIGNED_FIELDS, {
|
|
message: `Recipient ${recipient.id} has unsigned fields`,
|
|
statusCode: 400,
|
|
});
|
|
}
|
|
|
|
await prisma.$transaction(async (tx) => {
|
|
// Conditional update so two concurrent completion requests can't both
|
|
// proceed: only the request that transitions the recipient to SIGNED
|
|
// continues, the loser sees a count of 0 and aborts.
|
|
const { count: updatedRecipientCount } = await tx.recipient.updateMany({
|
|
where: {
|
|
id: recipient.id,
|
|
signingStatus: {
|
|
not: SigningStatus.SIGNED,
|
|
},
|
|
},
|
|
data: {
|
|
signingStatus: SigningStatus.SIGNED,
|
|
signedAt: new Date(),
|
|
name: recipientName,
|
|
email: recipientEmail,
|
|
},
|
|
});
|
|
|
|
// A concurrent request completed the recipient between our initial read
|
|
// and this transaction. Abort so the winning request handles all side
|
|
// effects, the router resolves this code idempotently.
|
|
if (updatedRecipientCount === 0) {
|
|
throw new AppError(AppErrorCode.RECIPIENT_ALREADY_SIGNED, {
|
|
message: `Recipient ${recipient.id} has already signed`,
|
|
statusCode: 400,
|
|
});
|
|
}
|
|
|
|
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 jobs.triggerJob({
|
|
name: 'send.document.pending.email',
|
|
payload: {
|
|
envelopeId: envelope.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,
|
|
});
|
|
};
|