mirror of
https://github.com/documenso/documenso.git
synced 2026-07-10 04:55:02 +10:00
844af17ec2
Each direct mailer.sendMail() call is replaced by a dedicated background job so that email delivery failures can be retried independently. New jobs: send-pending-email, send-completed-email, send-forgot-password-email, send-document-super-delete-email, send-recipient-removed-email, send-document-deleted-emails, send-2fa-token-email, send-resend-document-email, send-direct-template-created-email. Existing handlers (send-document-cancelled-emails, send-organisation-member-*, send-recipient-signed-email) have io.runTask wrappers removed since they interfere with the job scheduler. Job triggers are dispatched after transactions commit to avoid race conditions with uncommitted data.
428 lines
12 KiB
TypeScript
428 lines
12 KiB
TypeScript
import {
|
|
DocumentSigningOrder,
|
|
DocumentStatus,
|
|
EnvelopeType,
|
|
FieldType,
|
|
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 } 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';
|
|
|
|
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`,
|
|
);
|
|
}
|
|
}
|
|
|
|
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`);
|
|
}
|
|
|
|
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',
|
|
});
|
|
}
|
|
|
|
// 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,
|
|
},
|
|
}),
|
|
});
|
|
}
|
|
|
|
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,
|
|
},
|
|
}),
|
|
});
|
|
});
|
|
|
|
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,
|
|
...(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,
|
|
});
|
|
};
|