mirror of
https://github.com/documenso/documenso.git
synced 2026-08-15 02:53:32 +10:00
Previously attempting to complete a document which is already completed you'd get a generic error toast. Now when completing a document that you have already completed you are redirected to the completed page. Handles cases where two mutations managed to fire racing eachother.
524 lines
16 KiB
TypeScript
524 lines
16 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.findFirstOrThrow({
|
|
where: {
|
|
...unsafeBuildEnvelopeIdQuery(id, EnvelopeType.DOCUMENT),
|
|
recipients: {
|
|
some: {
|
|
token,
|
|
},
|
|
},
|
|
},
|
|
include: {
|
|
documentMeta: true,
|
|
recipients: {
|
|
where: {
|
|
token,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
|
|
|
|
if (envelope.recipients.length === 0) {
|
|
throw new Error(`Document ${envelope.id} has no recipient with token ${token}`);
|
|
}
|
|
|
|
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) {
|
|
throw new Error(`Document ${envelope.id} must be pending`);
|
|
}
|
|
|
|
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 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) => {
|
|
// 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,
|
|
});
|
|
};
|