mirror of
https://github.com/documenso/documenso.git
synced 2026-06-22 04:12:06 +10:00
d5ce222482
Adds Cloud Signature Consortium (CSC) integration for AES/QES signing against a configured TSP. v1 ships as instance-wide configuration via environment variables, with per-envelope signature level selection, license gating, and an OAuth-driven signing flow (capture + FIFO signers, SAD session, blocking/in-progress recipient pages). Includes signature level compatibility checks (role, signing order, dictate next signer), envelope mutability assertions, Prisma migration for signature level and CSC tables, and docs for the new signing certificate options.
524 lines
16 KiB
TypeScript
524 lines
16 KiB
TypeScript
import { materializeTspAnchorsForEnvelope } from '@documenso/ee/server-only/signing/csc/materialize-anchors';
|
|
import { resolveExpiresAt } from '@documenso/lib/constants/envelope-expiration';
|
|
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
|
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
|
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
|
import { prisma } from '@documenso/prisma';
|
|
import { checkboxValidationSigns } from '@documenso/ui/primitives/document-flow/field-items-advanced-settings/constants';
|
|
import type { DocumentData, Envelope, EnvelopeItem, Field, Recipient } from '@prisma/client';
|
|
import {
|
|
DocumentSigningOrder,
|
|
DocumentStatus,
|
|
EnvelopeType,
|
|
FieldType,
|
|
RecipientRole,
|
|
SendStatus,
|
|
SigningStatus,
|
|
WebhookTriggerEvents,
|
|
} from '@prisma/client';
|
|
|
|
import { validateCheckboxLength } from '../../advanced-fields-validation/validate-checkbox';
|
|
import { DIRECT_TEMPLATE_RECIPIENT_EMAIL } from '../../constants/direct-templates';
|
|
import { AppError, AppErrorCode } from '../../errors/app-error';
|
|
import { jobs } from '../../jobs/client';
|
|
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
|
|
import {
|
|
ZCheckboxFieldMeta,
|
|
ZDropdownFieldMeta,
|
|
ZFieldAndMetaSchema,
|
|
ZNumberFieldMeta,
|
|
ZRadioFieldMeta,
|
|
ZTextFieldMeta,
|
|
} from '../../types/field-meta';
|
|
import { isTspEnvelope } from '../../types/signature-level';
|
|
import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../types/webhook-payload';
|
|
import { getFileServerSide } from '../../universal/upload/get-file.server';
|
|
import { putNormalizedPdfFileServerSide } from '../../universal/upload/put-file.server';
|
|
import { isDocumentCompleted } from '../../utils/document';
|
|
import { extractDocumentAuthMethods } from '../../utils/document-auth';
|
|
import { type EnvelopeIdOptions, mapSecondaryIdToDocumentId } from '../../utils/envelope';
|
|
import { toCheckboxCustomText, toRadioCustomText } from '../../utils/fields';
|
|
import { getRecipientsWithMissingFields, isRecipientEmailValidForSending } from '../../utils/recipients';
|
|
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
|
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
|
|
import { assertUserNotDisabledById } from '../user/assert-user-not-disabled';
|
|
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
|
|
|
export type SendDocumentOptions = {
|
|
id: EnvelopeIdOptions;
|
|
userId: number;
|
|
teamId: number;
|
|
sendEmail?: boolean;
|
|
requestMetadata: ApiRequestMetadata;
|
|
};
|
|
|
|
export const sendDocument = async ({ id, userId, teamId, sendEmail, requestMetadata }: SendDocumentOptions) => {
|
|
// Refuse to send on behalf of a disabled account. Guards distribute /
|
|
// redistribute / template-use routes, the bulk-send job, and direct
|
|
// templates that auto-send on creation.
|
|
await assertUserNotDisabledById({ userId });
|
|
|
|
const { envelopeWhereInput } = await getEnvelopeWhereInput({
|
|
id,
|
|
type: EnvelopeType.DOCUMENT,
|
|
userId,
|
|
teamId,
|
|
});
|
|
|
|
const envelope = await prisma.envelope.findFirst({
|
|
where: envelopeWhereInput,
|
|
include: {
|
|
recipients: {
|
|
orderBy: [{ signingOrder: { sort: 'asc', nulls: 'last' } }, { id: 'asc' }],
|
|
},
|
|
fields: true,
|
|
documentMeta: true,
|
|
envelopeItems: {
|
|
select: {
|
|
id: true,
|
|
documentData: {
|
|
select: {
|
|
type: true,
|
|
id: true,
|
|
data: true,
|
|
initialData: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
team: {
|
|
select: {
|
|
organisation: {
|
|
select: {
|
|
organisationClaim: {
|
|
select: {
|
|
recipientCount: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!envelope) {
|
|
throw new Error('Document not found');
|
|
}
|
|
|
|
if (envelope.recipients.length === 0) {
|
|
throw new Error('Document has no recipients');
|
|
}
|
|
|
|
// A recipientCount of 0 means unlimited recipients are allowed.
|
|
const maximumRecipientCount = envelope.team.organisation.organisationClaim.recipientCount;
|
|
|
|
if (maximumRecipientCount > 0 && envelope.recipients.length > maximumRecipientCount) {
|
|
throw new AppError('RECIPIENT_LIMIT_EXCEEDED', {
|
|
message: `You cannot send a document with more than ${maximumRecipientCount} recipients`,
|
|
statusCode: 400,
|
|
});
|
|
}
|
|
|
|
if (isDocumentCompleted(envelope.status)) {
|
|
throw new Error('Can not send completed document');
|
|
}
|
|
|
|
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
|
|
|
|
let signingOrder = envelope.documentMeta?.signingOrder || DocumentSigningOrder.PARALLEL;
|
|
|
|
if (isTspEnvelope(envelope) && signingOrder === DocumentSigningOrder.PARALLEL && envelope.documentMeta) {
|
|
console.warn(
|
|
`[CSC] Coercing signingOrder=PARALLEL → SEQUENTIAL for ${envelope.signatureLevel} envelope ${envelope.id} at send time. The schema-layer guard should have caught this earlier.`,
|
|
);
|
|
|
|
await prisma.documentMeta.update({
|
|
where: {
|
|
id: envelope.documentMeta.id,
|
|
},
|
|
data: {
|
|
signingOrder: DocumentSigningOrder.SEQUENTIAL,
|
|
},
|
|
});
|
|
|
|
signingOrder = DocumentSigningOrder.SEQUENTIAL;
|
|
|
|
envelope.documentMeta.signingOrder = DocumentSigningOrder.SEQUENTIAL;
|
|
}
|
|
|
|
let recipientsToNotify = envelope.recipients;
|
|
|
|
if (signingOrder === DocumentSigningOrder.SEQUENTIAL) {
|
|
// Get the currently active recipient.
|
|
recipientsToNotify = envelope.recipients
|
|
.filter((r) => r.signingStatus === SigningStatus.NOT_SIGNED && r.role !== RecipientRole.CC)
|
|
.slice(0, 1);
|
|
}
|
|
|
|
if (envelope.envelopeItems.length === 0) {
|
|
throw new Error('Missing envelope items');
|
|
}
|
|
|
|
if (envelope.formValues && envelope.status === DocumentStatus.DRAFT) {
|
|
await Promise.all(
|
|
envelope.envelopeItems.map(async (envelopeItem) => {
|
|
await injectFormValuesIntoDocument(envelope, envelopeItem);
|
|
}),
|
|
);
|
|
}
|
|
|
|
// Validate that recipients with auth requirements have a valid email.
|
|
envelope.recipients.forEach((recipient) => {
|
|
const auth = extractDocumentAuthMethods({
|
|
documentAuth: envelope.authOptions,
|
|
recipientAuth: recipient.authOptions,
|
|
});
|
|
|
|
if (
|
|
recipient.role !== RecipientRole.CC &&
|
|
(auth.recipientAccessAuthRequired || auth.recipientActionAuthRequired) &&
|
|
!isRecipientEmailValidForSending(recipient)
|
|
) {
|
|
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
|
message: `Recipient ${recipient.id} requires an email because they have auth requirements.`,
|
|
});
|
|
}
|
|
});
|
|
|
|
// Validate that recipients who require fields (e.g., signers need signature fields) have them.
|
|
const recipientsWithMissingFields = getRecipientsWithMissingFields(envelope.recipients, envelope.fields);
|
|
|
|
if (recipientsWithMissingFields.length > 0) {
|
|
const missingRecipientDescriptions = recipientsWithMissingFields
|
|
.map((r) => (r.name ? `${r.name} (${r.email}, id: ${r.id})` : `${r.email} (id: ${r.id})`))
|
|
.join(', ');
|
|
|
|
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
|
message: `The following recipients are missing required fields: ${missingRecipientDescriptions}. Signers must have at least one signature field.`,
|
|
});
|
|
}
|
|
|
|
const allRecipientsHaveNoActionToTake = envelope.recipients.every(
|
|
(recipient) => recipient.role === RecipientRole.CC || recipient.signingStatus === SigningStatus.SIGNED,
|
|
);
|
|
|
|
if (allRecipientsHaveNoActionToTake) {
|
|
await jobs.triggerJob({
|
|
name: 'internal.seal-document',
|
|
payload: {
|
|
documentId: legacyDocumentId,
|
|
requestMetadata: requestMetadata?.requestMetadata,
|
|
},
|
|
});
|
|
|
|
// Keep the return type the same for the `sendDocument` method
|
|
return await prisma.envelope.findFirstOrThrow({
|
|
where: {
|
|
id: envelope.id,
|
|
},
|
|
include: {
|
|
documentMeta: true,
|
|
recipients: true,
|
|
},
|
|
});
|
|
}
|
|
|
|
const fieldsToAutoInsert: { fieldId: number; customText: string }[] = [];
|
|
|
|
// Validate and autoinsert fields for V2 envelopes.
|
|
if (envelope.internalVersion === 2) {
|
|
for (const unknownField of envelope.fields) {
|
|
const recipient = envelope.recipients.find((r) => r.id === unknownField.recipientId);
|
|
|
|
if (!recipient) {
|
|
throw new AppError(AppErrorCode.NOT_FOUND, {
|
|
message: 'Recipient not found',
|
|
});
|
|
}
|
|
|
|
const fieldToAutoInsert = extractFieldAutoInsertValues(unknownField, recipient);
|
|
|
|
// Only auto-insert fields if the recipient has not been sent the document yet.
|
|
if (fieldToAutoInsert && recipient.sendStatus !== SendStatus.SENT) {
|
|
fieldsToAutoInsert.push(fieldToAutoInsert);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (isTspEnvelope(envelope) && envelope.status === DocumentStatus.DRAFT) {
|
|
await materializeTspAnchorsForEnvelope({
|
|
envelopeId: envelope.id,
|
|
});
|
|
}
|
|
|
|
const updatedEnvelope = await prisma.$transaction(async (tx) => {
|
|
if (envelope.status === DocumentStatus.DRAFT) {
|
|
await tx.documentAuditLog.create({
|
|
data: createDocumentAuditLogData({
|
|
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT,
|
|
envelopeId: envelope.id,
|
|
metadata: requestMetadata,
|
|
data: {},
|
|
}),
|
|
});
|
|
}
|
|
|
|
if (envelope.internalVersion === 2) {
|
|
const autoInsertedFields = await Promise.all(
|
|
fieldsToAutoInsert.map(async (field) => {
|
|
// Warning: Only auto-insert fields if the recipient has not been sent the document yet.
|
|
return await tx.field.update({
|
|
where: {
|
|
id: field.fieldId,
|
|
},
|
|
data: {
|
|
customText: field.customText,
|
|
inserted: true,
|
|
},
|
|
});
|
|
}),
|
|
);
|
|
|
|
await tx.documentAuditLog.create({
|
|
data: createDocumentAuditLogData({
|
|
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELDS_AUTO_INSERTED,
|
|
envelopeId: envelope.id,
|
|
data: {
|
|
fields: autoInsertedFields.map((field) => ({
|
|
fieldId: field.id,
|
|
fieldType: field.type,
|
|
recipientId: field.recipientId,
|
|
})),
|
|
},
|
|
// Don't put metadata or user here since it's a system event.
|
|
}),
|
|
});
|
|
}
|
|
|
|
const expiresAt = resolveExpiresAt(envelope.documentMeta?.envelopeExpirationPeriod ?? null);
|
|
|
|
// Set expiresAt on each recipient that hasn't already signed/rejected.
|
|
// Exclude CC recipients since they don't sign and shouldn't be subject to expiry.
|
|
if (expiresAt) {
|
|
await tx.recipient.updateMany({
|
|
where: {
|
|
envelopeId: envelope.id,
|
|
signingStatus: {
|
|
notIn: [SigningStatus.SIGNED, SigningStatus.REJECTED],
|
|
},
|
|
role: {
|
|
not: RecipientRole.CC,
|
|
},
|
|
},
|
|
data: {
|
|
expiresAt,
|
|
expirationNotifiedAt: null,
|
|
},
|
|
});
|
|
}
|
|
|
|
return await tx.envelope.update({
|
|
where: {
|
|
id: envelope.id,
|
|
},
|
|
data: {
|
|
status: DocumentStatus.PENDING,
|
|
},
|
|
include: {
|
|
documentMeta: true,
|
|
recipients: true,
|
|
},
|
|
});
|
|
});
|
|
|
|
const isRecipientSigningRequestEmailEnabled = extractDerivedDocumentEmailSettings(
|
|
envelope.documentMeta,
|
|
).recipientSigningRequest;
|
|
|
|
// Only send email if one of the following is true:
|
|
// - It is explicitly set
|
|
// - The email is enabled for signing requests AND sendEmail is undefined
|
|
if (sendEmail || (isRecipientSigningRequestEmailEnabled && sendEmail === undefined)) {
|
|
await Promise.all(
|
|
recipientsToNotify.map(async (recipient) => {
|
|
if (recipient.sendStatus === SendStatus.SENT || recipient.role === RecipientRole.CC) {
|
|
return;
|
|
}
|
|
|
|
await jobs.triggerJob({
|
|
name: 'send.signing.requested.email',
|
|
payload: {
|
|
userId,
|
|
documentId: legacyDocumentId,
|
|
recipientId: recipient.id,
|
|
requestMetadata: requestMetadata?.requestMetadata,
|
|
},
|
|
});
|
|
}),
|
|
);
|
|
}
|
|
|
|
await triggerWebhook({
|
|
event: WebhookTriggerEvents.DOCUMENT_SENT,
|
|
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(updatedEnvelope)),
|
|
userId,
|
|
teamId,
|
|
});
|
|
|
|
return updatedEnvelope;
|
|
};
|
|
|
|
const injectFormValuesIntoDocument = async (
|
|
envelope: Envelope,
|
|
envelopeItem: Pick<EnvelopeItem, 'id'> & { documentData: DocumentData },
|
|
) => {
|
|
const file = await getFileServerSide(envelopeItem.documentData);
|
|
|
|
const prefilled = await insertFormValuesInPdf({
|
|
pdf: Buffer.from(file),
|
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
formValues: envelope.formValues as Record<string, string | number | boolean>,
|
|
});
|
|
|
|
let fileName = envelope.title;
|
|
|
|
if (!envelope.title.endsWith('.pdf')) {
|
|
fileName = `${envelope.title}.pdf`;
|
|
}
|
|
|
|
const newDocumentData = await putNormalizedPdfFileServerSide({
|
|
name: fileName,
|
|
type: 'application/pdf',
|
|
arrayBuffer: async () => Promise.resolve(prefilled),
|
|
});
|
|
|
|
await prisma.envelopeItem.update({
|
|
where: {
|
|
id: envelopeItem.id,
|
|
},
|
|
data: {
|
|
documentDataId: newDocumentData.id,
|
|
},
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Extracts the auto insertion values for a given field.
|
|
*
|
|
* If field is not auto insertable, returns `null`.
|
|
*/
|
|
export const extractFieldAutoInsertValues = (
|
|
unknownField: Field,
|
|
recipient: Pick<Recipient, 'email'>,
|
|
): { fieldId: number; customText: string } | null => {
|
|
const parsedField = ZFieldAndMetaSchema.safeParse(unknownField);
|
|
|
|
if (parsedField.error) {
|
|
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
|
message: 'One or more fields have invalid metadata. Error: ' + parsedField.error.message,
|
|
});
|
|
}
|
|
|
|
const field = parsedField.data;
|
|
const fieldId = unknownField.id;
|
|
|
|
// Auto insert email fields if the recipient has a valid email.
|
|
if (
|
|
field.type === FieldType.EMAIL &&
|
|
isRecipientEmailValidForSending(recipient) &&
|
|
recipient.email !== DIRECT_TEMPLATE_RECIPIENT_EMAIL
|
|
) {
|
|
return {
|
|
fieldId,
|
|
customText: recipient.email,
|
|
};
|
|
}
|
|
|
|
// Auto insert text fields with prefilled values.
|
|
if (field.type === FieldType.TEXT) {
|
|
const { text } = ZTextFieldMeta.parse(field.fieldMeta);
|
|
|
|
if (text) {
|
|
return {
|
|
fieldId,
|
|
customText: text,
|
|
};
|
|
}
|
|
}
|
|
|
|
// Auto insert number fields with prefilled values.
|
|
if (field.type === FieldType.NUMBER) {
|
|
const { value } = ZNumberFieldMeta.parse(field.fieldMeta);
|
|
|
|
if (value) {
|
|
return {
|
|
fieldId,
|
|
customText: value,
|
|
};
|
|
}
|
|
}
|
|
|
|
// Auto insert radio fields with the pre-checked value.
|
|
if (field.type === FieldType.RADIO) {
|
|
const { values = [] } = ZRadioFieldMeta.parse(field.fieldMeta);
|
|
|
|
const checkedItemIndex = values.findIndex((value) => value.checked);
|
|
|
|
if (checkedItemIndex !== -1) {
|
|
return {
|
|
fieldId,
|
|
customText: toRadioCustomText(checkedItemIndex),
|
|
};
|
|
}
|
|
}
|
|
|
|
// Auto insert dropdown fields with the default value.
|
|
if (field.type === FieldType.DROPDOWN) {
|
|
const { defaultValue, values = [] } = ZDropdownFieldMeta.parse(field.fieldMeta);
|
|
|
|
if (defaultValue && values.some((value) => value.value === defaultValue)) {
|
|
return {
|
|
fieldId,
|
|
customText: defaultValue,
|
|
};
|
|
}
|
|
}
|
|
|
|
// Auto insert checkbox fields with the pre-checked values.
|
|
if (field.type === FieldType.CHECKBOX) {
|
|
const { values = [], validationRule, validationLength } = ZCheckboxFieldMeta.parse(field.fieldMeta);
|
|
|
|
const checkedIndices: number[] = [];
|
|
|
|
values.forEach((value, i) => {
|
|
if (value.checked) {
|
|
checkedIndices.push(i);
|
|
}
|
|
});
|
|
|
|
let isValid = true;
|
|
|
|
if (validationRule && validationLength) {
|
|
const validation = checkboxValidationSigns.find((sign) => sign.label === validationRule);
|
|
|
|
if (!validation) {
|
|
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
|
message: 'Invalid checkbox validation rule',
|
|
});
|
|
}
|
|
|
|
isValid = validateCheckboxLength(checkedIndices.length, validation.value, validationLength);
|
|
}
|
|
|
|
if (isValid && checkedIndices.length > 0) {
|
|
return {
|
|
fieldId,
|
|
customText: toCheckboxCustomText(checkedIndices),
|
|
};
|
|
}
|
|
}
|
|
|
|
return null;
|
|
};
|