mirror of
https://github.com/documenso/documenso.git
synced 2025-11-12 15:53:02 +10:00
This PR is handles the changes required to support envelopes. The new envelope editor/signing page will be hidden during release. The core changes here is to migrate the documents and templates model to a centralized envelopes model. Even though Documents and Templates are removed, from the user perspective they will still exist as we remap envelopes to documents and templates.
328 lines
10 KiB
TypeScript
328 lines
10 KiB
TypeScript
import { DocumentStatus, FieldType, RecipientRole, SigningStatus } from '@prisma/client';
|
|
import { DateTime } from 'luxon';
|
|
import { isDeepEqual } from 'remeda';
|
|
import { match } from 'ts-pattern';
|
|
|
|
import { validateCheckboxField } from '@documenso/lib/advanced-fields-validation/validate-checkbox';
|
|
import { validateDropdownField } from '@documenso/lib/advanced-fields-validation/validate-dropdown';
|
|
import { validateNumberField } from '@documenso/lib/advanced-fields-validation/validate-number';
|
|
import { validateRadioField } from '@documenso/lib/advanced-fields-validation/validate-radio';
|
|
import { validateTextField } from '@documenso/lib/advanced-fields-validation/validate-text';
|
|
import { fromCheckboxValue } from '@documenso/lib/universal/field-checkbox';
|
|
import { prisma } from '@documenso/prisma';
|
|
|
|
import { AUTO_SIGNABLE_FIELD_TYPES } from '../../constants/autosign';
|
|
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../../constants/date-formats';
|
|
import { DEFAULT_DOCUMENT_TIME_ZONE } from '../../constants/time-zones';
|
|
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
|
import type { TRecipientActionAuth } from '../../types/document-auth';
|
|
import {
|
|
ZCheckboxFieldMeta,
|
|
ZDropdownFieldMeta,
|
|
ZNumberFieldMeta,
|
|
ZRadioFieldMeta,
|
|
ZTextFieldMeta,
|
|
} from '../../types/field-meta';
|
|
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
|
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
|
import { validateFieldAuth } from '../document/validate-field-auth';
|
|
|
|
export type SignFieldWithTokenOptions = {
|
|
token: string;
|
|
fieldId: number;
|
|
value: string;
|
|
isBase64?: boolean;
|
|
userId?: number;
|
|
authOptions?: TRecipientActionAuth;
|
|
requestMetadata?: RequestMetadata;
|
|
};
|
|
|
|
/**
|
|
* Please read.
|
|
*
|
|
* Content within this function has been duplicated in the
|
|
* createDocumentFromDirectTemplate file.
|
|
*
|
|
* Any update to this should be reflected in the other file if required.
|
|
*
|
|
* Todo: Extract common logic.
|
|
*/
|
|
export const signFieldWithToken = async ({
|
|
token,
|
|
fieldId,
|
|
value,
|
|
isBase64,
|
|
userId,
|
|
authOptions,
|
|
requestMetadata,
|
|
}: SignFieldWithTokenOptions) => {
|
|
const recipient = await prisma.recipient.findFirstOrThrow({
|
|
where: {
|
|
token,
|
|
},
|
|
});
|
|
|
|
const field = await prisma.field.findFirstOrThrow({
|
|
where: {
|
|
id: fieldId,
|
|
recipient: {
|
|
...(recipient.role !== RecipientRole.ASSISTANT
|
|
? {
|
|
id: recipient.id,
|
|
}
|
|
: {
|
|
signingStatus: {
|
|
not: SigningStatus.SIGNED,
|
|
},
|
|
signingOrder: {
|
|
gte: recipient.signingOrder ?? 0,
|
|
},
|
|
}),
|
|
},
|
|
},
|
|
include: {
|
|
envelope: {
|
|
include: {
|
|
recipients: true,
|
|
},
|
|
},
|
|
recipient: true,
|
|
},
|
|
});
|
|
|
|
const { envelope } = field;
|
|
|
|
if (!envelope) {
|
|
throw new Error(`Document not found for field ${field.id}`);
|
|
}
|
|
|
|
if (!recipient) {
|
|
throw new Error(`Recipient not found for field ${field.id}`);
|
|
}
|
|
|
|
if (envelope.deletedAt) {
|
|
throw new Error(`Document ${envelope.id} has been deleted`);
|
|
}
|
|
|
|
if (envelope.status !== DocumentStatus.PENDING) {
|
|
throw new Error(`Document ${envelope.id} must be pending for signing`);
|
|
}
|
|
|
|
if (
|
|
recipient.signingStatus === SigningStatus.SIGNED ||
|
|
field.recipient.signingStatus === SigningStatus.SIGNED
|
|
) {
|
|
throw new Error(`Recipient ${recipient.id} has already signed`);
|
|
}
|
|
|
|
if (field.inserted) {
|
|
throw new Error(`Field ${fieldId} has already been inserted`);
|
|
}
|
|
|
|
// Unreachable code based on the above query but we need to satisfy TypeScript
|
|
if (field.recipientId === null) {
|
|
throw new Error(`Field ${fieldId} has no recipientId`);
|
|
}
|
|
|
|
if (field.type === FieldType.NUMBER && field.fieldMeta) {
|
|
const numberFieldParsedMeta = ZNumberFieldMeta.parse(field.fieldMeta);
|
|
const errors = validateNumberField(value, numberFieldParsedMeta, true);
|
|
|
|
if (errors.length > 0) {
|
|
throw new Error(errors.join(', '));
|
|
}
|
|
}
|
|
|
|
if (field.type === FieldType.TEXT && field.fieldMeta) {
|
|
const textFieldParsedMeta = ZTextFieldMeta.parse(field.fieldMeta);
|
|
const errors = validateTextField(value, textFieldParsedMeta, true);
|
|
|
|
if (errors.length > 0) {
|
|
throw new Error(errors.join(', '));
|
|
}
|
|
}
|
|
|
|
if (field.type === FieldType.CHECKBOX && field.fieldMeta) {
|
|
const checkboxFieldParsedMeta = ZCheckboxFieldMeta.parse(field.fieldMeta);
|
|
const checkboxFieldValues: string[] = fromCheckboxValue(value);
|
|
|
|
const errors = validateCheckboxField(checkboxFieldValues, checkboxFieldParsedMeta, true);
|
|
|
|
if (errors.length > 0) {
|
|
throw new Error(errors.join(', '));
|
|
}
|
|
}
|
|
|
|
if (field.type === FieldType.RADIO && field.fieldMeta) {
|
|
const radioFieldParsedMeta = ZRadioFieldMeta.parse(field.fieldMeta);
|
|
const errors = validateRadioField(value, radioFieldParsedMeta, true);
|
|
|
|
if (errors.length > 0) {
|
|
throw new Error(errors.join(', '));
|
|
}
|
|
}
|
|
|
|
if (field.type === FieldType.DROPDOWN && field.fieldMeta) {
|
|
const dropdownFieldParsedMeta = ZDropdownFieldMeta.parse(field.fieldMeta);
|
|
const errors = validateDropdownField(value, dropdownFieldParsedMeta, true);
|
|
|
|
if (errors.length > 0) {
|
|
throw new Error(errors.join(', '));
|
|
}
|
|
}
|
|
|
|
const derivedRecipientActionAuth = await validateFieldAuth({
|
|
documentAuthOptions: envelope.authOptions,
|
|
recipient,
|
|
field,
|
|
userId,
|
|
authOptions,
|
|
});
|
|
|
|
const documentMeta = await prisma.documentMeta.findFirst({
|
|
where: {
|
|
envelope: {
|
|
id: envelope.id,
|
|
},
|
|
},
|
|
});
|
|
|
|
const isSignatureField =
|
|
field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE;
|
|
|
|
let customText = !isSignatureField ? value : undefined;
|
|
|
|
const signatureImageAsBase64 = isSignatureField && isBase64 ? value : undefined;
|
|
const typedSignature = isSignatureField && !isBase64 ? value : undefined;
|
|
|
|
if (field.type === FieldType.DATE) {
|
|
customText = DateTime.now()
|
|
.setZone(documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE)
|
|
.toFormat(documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT);
|
|
}
|
|
|
|
if (isSignatureField && !signatureImageAsBase64 && !typedSignature) {
|
|
throw new Error('Signature field must have a signature');
|
|
}
|
|
|
|
if (isSignatureField && documentMeta?.typedSignatureEnabled === false && typedSignature) {
|
|
throw new Error('Typed signatures are not allowed. Please draw your signature');
|
|
}
|
|
|
|
if (field.fieldMeta?.readOnly && !AUTO_SIGNABLE_FIELD_TYPES.includes(field.type)) {
|
|
// !: This is a bit of a hack at the moment, readonly fields with default values
|
|
// !: should be inserted with their default value on document creation instead of
|
|
// !: this weird programattic approach. Until that's fixed though this will verify
|
|
// !: that the programmatic signed value is only that of its default.
|
|
const isAutomaticSigningValueValid = match(field.fieldMeta)
|
|
.with({ type: 'text' }, (meta) => customText === meta.text)
|
|
.with({ type: 'number' }, (meta) => customText === meta.value)
|
|
.with({ type: 'checkbox' }, (meta) =>
|
|
isDeepEqual(
|
|
fromCheckboxValue(customText ?? ''),
|
|
meta.values?.filter((v) => v.checked).map((v) => v.value) ?? [],
|
|
),
|
|
)
|
|
.with({ type: 'radio' }, (meta) => customText === meta.values?.find((v) => v.checked)?.value)
|
|
.with({ type: 'dropdown' }, (meta) => customText === meta.defaultValue)
|
|
.otherwise(() => false);
|
|
|
|
if (!isAutomaticSigningValueValid) {
|
|
throw new Error('Field is read only and only accepts its default value for signing.');
|
|
}
|
|
}
|
|
|
|
const assistant = recipient.role === RecipientRole.ASSISTANT ? recipient : undefined;
|
|
|
|
return await prisma.$transaction(async (tx) => {
|
|
const updatedField = await tx.field.update({
|
|
where: {
|
|
id: field.id,
|
|
},
|
|
data: {
|
|
customText,
|
|
inserted: true,
|
|
},
|
|
});
|
|
|
|
if (isSignatureField) {
|
|
const signature = await tx.signature.upsert({
|
|
where: {
|
|
fieldId: field.id,
|
|
},
|
|
create: {
|
|
fieldId: field.id,
|
|
recipientId: field.recipientId,
|
|
signatureImageAsBase64: signatureImageAsBase64,
|
|
typedSignature: typedSignature,
|
|
},
|
|
update: {
|
|
signatureImageAsBase64: signatureImageAsBase64,
|
|
typedSignature: typedSignature,
|
|
},
|
|
});
|
|
|
|
// Dirty but I don't want to deal with type information
|
|
Object.assign(updatedField, {
|
|
signature,
|
|
});
|
|
}
|
|
|
|
await tx.documentAuditLog.create({
|
|
data: createDocumentAuditLogData({
|
|
type:
|
|
assistant && field.recipientId !== assistant.id
|
|
? DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_PREFILLED
|
|
: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED,
|
|
envelopeId: envelope.id,
|
|
user: {
|
|
email: assistant?.email ?? recipient.email,
|
|
name: assistant?.name ?? recipient.name,
|
|
},
|
|
requestMetadata,
|
|
data: {
|
|
recipientEmail: recipient.email,
|
|
recipientId: recipient.id,
|
|
recipientName: recipient.name,
|
|
recipientRole: recipient.role,
|
|
fieldId: updatedField.secondaryId,
|
|
field: match(updatedField.type)
|
|
.with(FieldType.SIGNATURE, FieldType.FREE_SIGNATURE, (type) => ({
|
|
type,
|
|
data: signatureImageAsBase64 || typedSignature || '',
|
|
}))
|
|
.with(
|
|
FieldType.DATE,
|
|
FieldType.EMAIL,
|
|
FieldType.NAME,
|
|
FieldType.TEXT,
|
|
FieldType.INITIALS,
|
|
(type) => ({
|
|
type,
|
|
data: updatedField.customText,
|
|
}),
|
|
)
|
|
.with(
|
|
FieldType.NUMBER,
|
|
FieldType.RADIO,
|
|
FieldType.CHECKBOX,
|
|
FieldType.DROPDOWN,
|
|
(type) => ({
|
|
type,
|
|
data: updatedField.customText,
|
|
}),
|
|
)
|
|
.exhaustive(),
|
|
fieldSecurity: derivedRecipientActionAuth
|
|
? {
|
|
type: derivedRecipientActionAuth,
|
|
}
|
|
: undefined,
|
|
},
|
|
}),
|
|
});
|
|
|
|
return updatedField;
|
|
});
|
|
};
|