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.
386 lines
12 KiB
TypeScript
386 lines
12 KiB
TypeScript
import { EnvelopeType, type Field, FieldType } from '@prisma/client';
|
|
import { isDeepEqual } from 'remeda';
|
|
|
|
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 { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
|
import {
|
|
type TFieldMetaSchema as FieldMeta,
|
|
ZCheckboxFieldMeta,
|
|
ZDropdownFieldMeta,
|
|
ZFieldMetaSchema,
|
|
ZNumberFieldMeta,
|
|
ZRadioFieldMeta,
|
|
ZTextFieldMeta,
|
|
} from '@documenso/lib/types/field-meta';
|
|
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
|
import {
|
|
createDocumentAuditLogData,
|
|
diffFieldChanges,
|
|
} from '@documenso/lib/utils/document-audit-logs';
|
|
import { prisma } from '@documenso/prisma';
|
|
|
|
import { AppError, AppErrorCode } from '../../errors/app-error';
|
|
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
|
import { mapFieldToLegacyField } from '../../utils/fields';
|
|
import { canRecipientFieldsBeModified } from '../../utils/recipients';
|
|
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
|
|
|
export interface SetFieldsForDocumentOptions {
|
|
userId: number;
|
|
teamId: number;
|
|
id: EnvelopeIdOptions;
|
|
fields: FieldData[];
|
|
requestMetadata: ApiRequestMetadata;
|
|
}
|
|
|
|
export const setFieldsForDocument = async ({
|
|
userId,
|
|
teamId,
|
|
id,
|
|
fields,
|
|
requestMetadata,
|
|
}: SetFieldsForDocumentOptions) => {
|
|
const { envelopeWhereInput } = await getEnvelopeWhereInput({
|
|
id,
|
|
type: EnvelopeType.DOCUMENT,
|
|
userId,
|
|
teamId,
|
|
});
|
|
|
|
const envelope = await prisma.envelope.findFirst({
|
|
where: envelopeWhereInput,
|
|
include: {
|
|
recipients: true,
|
|
envelopeItems: {
|
|
select: {
|
|
id: true,
|
|
},
|
|
},
|
|
fields: {
|
|
include: {
|
|
recipient: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!envelope) {
|
|
throw new AppError(AppErrorCode.NOT_FOUND, {
|
|
message: 'Document not found',
|
|
});
|
|
}
|
|
|
|
if (envelope.completedAt) {
|
|
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
|
message: 'Document already complete',
|
|
});
|
|
}
|
|
|
|
const existingFields = envelope.fields;
|
|
|
|
const removedFields = existingFields.filter(
|
|
(existingField) => !fields.find((field) => field.id === existingField.id),
|
|
);
|
|
|
|
const linkedFields = fields.map((field) => {
|
|
const existing = existingFields.find((existingField) => existingField.id === field.id);
|
|
|
|
const recipient = envelope.recipients.find((recipient) => recipient.id === field.recipientId);
|
|
|
|
// Check whether the field is being attached to an allowed envelope item.
|
|
const foundEnvelopeItem = envelope.envelopeItems.find(
|
|
(envelopeItem) => envelopeItem.id === field.envelopeItemId,
|
|
);
|
|
|
|
if (!foundEnvelopeItem) {
|
|
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
|
message: `Envelope item ${field.envelopeItemId} not found`,
|
|
});
|
|
}
|
|
|
|
// Each field MUST have a recipient associated with it.
|
|
if (!recipient) {
|
|
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
|
message: `Recipient not found for field ${field.id}`,
|
|
});
|
|
}
|
|
|
|
// Check whether the existing field can be modified.
|
|
if (
|
|
existing &&
|
|
hasFieldBeenChanged(existing, field) &&
|
|
!canRecipientFieldsBeModified(recipient, existingFields)
|
|
) {
|
|
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
|
message:
|
|
'Cannot modify a field where the recipient has already interacted with the document',
|
|
});
|
|
}
|
|
|
|
// Prevent creating new fields when recipient has interacted with the document.
|
|
if (!existing && !canRecipientFieldsBeModified(recipient, existingFields)) {
|
|
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
|
message:
|
|
'Cannot modify a field where the recipient has already interacted with the document',
|
|
});
|
|
}
|
|
|
|
return {
|
|
...field,
|
|
_persisted: existing,
|
|
_recipient: recipient,
|
|
};
|
|
});
|
|
|
|
const persistedFields = await prisma.$transaction(async (tx) => {
|
|
return await Promise.all(
|
|
linkedFields.map(async (field) => {
|
|
const fieldSignerEmail = field._recipient.email.toLowerCase();
|
|
|
|
const parsedFieldMeta = field.fieldMeta
|
|
? ZFieldMetaSchema.parse(field.fieldMeta)
|
|
: undefined;
|
|
|
|
if (field.type === FieldType.TEXT && field.fieldMeta) {
|
|
const textFieldParsedMeta = ZTextFieldMeta.parse(field.fieldMeta);
|
|
const errors = validateTextField(textFieldParsedMeta.text || '', textFieldParsedMeta);
|
|
|
|
if (errors.length > 0) {
|
|
throw new Error(errors.join(', '));
|
|
}
|
|
}
|
|
|
|
if (field.type === FieldType.NUMBER && field.fieldMeta) {
|
|
const numberFieldParsedMeta = ZNumberFieldMeta.parse(field.fieldMeta);
|
|
const errors = validateNumberField(
|
|
String(numberFieldParsedMeta.value),
|
|
numberFieldParsedMeta,
|
|
);
|
|
|
|
if (errors.length > 0) {
|
|
throw new Error(errors.join(', '));
|
|
}
|
|
}
|
|
|
|
if (field.type === FieldType.CHECKBOX) {
|
|
if (field.fieldMeta) {
|
|
const checkboxFieldParsedMeta = ZCheckboxFieldMeta.parse(field.fieldMeta);
|
|
const errors = validateCheckboxField(
|
|
checkboxFieldParsedMeta?.values?.map((item) => item.value) ?? [],
|
|
checkboxFieldParsedMeta,
|
|
);
|
|
|
|
if (errors.length > 0) {
|
|
throw new Error(errors.join(', '));
|
|
}
|
|
} else {
|
|
throw new Error(
|
|
'To proceed further, please set at least one value for the Checkbox field',
|
|
);
|
|
}
|
|
}
|
|
|
|
if (field.type === FieldType.RADIO) {
|
|
if (field.fieldMeta) {
|
|
const radioFieldParsedMeta = ZRadioFieldMeta.parse(field.fieldMeta);
|
|
const checkedRadioFieldValue = radioFieldParsedMeta.values?.find(
|
|
(option) => option.checked,
|
|
)?.value;
|
|
|
|
const errors = validateRadioField(checkedRadioFieldValue, radioFieldParsedMeta);
|
|
|
|
if (errors.length > 0) {
|
|
throw new Error(errors.join('. '));
|
|
}
|
|
} else {
|
|
throw new Error(
|
|
'To proceed further, please set at least one value for the Radio field',
|
|
);
|
|
}
|
|
}
|
|
|
|
if (field.type === FieldType.DROPDOWN) {
|
|
if (field.fieldMeta) {
|
|
const dropdownFieldParsedMeta = ZDropdownFieldMeta.parse(field.fieldMeta);
|
|
const errors = validateDropdownField(undefined, dropdownFieldParsedMeta);
|
|
|
|
if (errors.length > 0) {
|
|
throw new Error(errors.join('. '));
|
|
}
|
|
} else {
|
|
throw new Error(
|
|
'To proceed further, please set at least one value for the Dropdown field',
|
|
);
|
|
}
|
|
}
|
|
|
|
const upsertedField = await tx.field.upsert({
|
|
where: {
|
|
id: field._persisted?.id ?? -1,
|
|
envelopeId: envelope.id,
|
|
envelopeItemId: field.envelopeItemId,
|
|
},
|
|
update: {
|
|
page: field.pageNumber,
|
|
positionX: field.pageX,
|
|
positionY: field.pageY,
|
|
width: field.pageWidth,
|
|
height: field.pageHeight,
|
|
fieldMeta: parsedFieldMeta,
|
|
},
|
|
create: {
|
|
type: field.type,
|
|
page: field.pageNumber,
|
|
positionX: field.pageX,
|
|
positionY: field.pageY,
|
|
width: field.pageWidth,
|
|
height: field.pageHeight,
|
|
customText: '',
|
|
inserted: false,
|
|
fieldMeta: parsedFieldMeta,
|
|
envelope: {
|
|
connect: {
|
|
id: envelope.id,
|
|
},
|
|
},
|
|
envelopeItem: {
|
|
connect: {
|
|
id: field.envelopeItemId,
|
|
envelopeId: envelope.id,
|
|
},
|
|
},
|
|
recipient: {
|
|
connect: {
|
|
id: field._recipient.id,
|
|
envelopeId: envelope.id,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (upsertedField.recipientId === null) {
|
|
throw new Error('Not possible');
|
|
}
|
|
|
|
const baseAuditLog = {
|
|
fieldId: upsertedField.secondaryId,
|
|
fieldRecipientEmail: fieldSignerEmail,
|
|
fieldRecipientId: upsertedField.recipientId,
|
|
fieldType: upsertedField.type,
|
|
};
|
|
|
|
const changes = field._persisted ? diffFieldChanges(field._persisted, upsertedField) : [];
|
|
|
|
// Handle field updated audit log.
|
|
if (field._persisted && changes.length > 0) {
|
|
await tx.documentAuditLog.create({
|
|
data: createDocumentAuditLogData({
|
|
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_UPDATED,
|
|
envelopeId: envelope.id,
|
|
metadata: requestMetadata,
|
|
data: {
|
|
changes,
|
|
...baseAuditLog,
|
|
},
|
|
}),
|
|
});
|
|
}
|
|
|
|
// Handle field created audit log.
|
|
if (!field._persisted) {
|
|
await tx.documentAuditLog.create({
|
|
data: createDocumentAuditLogData({
|
|
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_CREATED,
|
|
envelopeId: envelope.id,
|
|
metadata: requestMetadata,
|
|
data: {
|
|
...baseAuditLog,
|
|
},
|
|
}),
|
|
});
|
|
}
|
|
|
|
return upsertedField;
|
|
}),
|
|
);
|
|
});
|
|
|
|
if (removedFields.length > 0) {
|
|
await prisma.$transaction(async (tx) => {
|
|
await tx.field.deleteMany({
|
|
where: {
|
|
id: {
|
|
in: removedFields.map((field) => field.id),
|
|
},
|
|
},
|
|
});
|
|
|
|
await tx.documentAuditLog.createMany({
|
|
data: removedFields.map((field) =>
|
|
createDocumentAuditLogData({
|
|
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_DELETED,
|
|
envelopeId: envelope.id,
|
|
metadata: requestMetadata,
|
|
data: {
|
|
fieldId: field.secondaryId,
|
|
fieldRecipientEmail: field.recipient?.email ?? '',
|
|
fieldRecipientId: field.recipientId ?? -1,
|
|
fieldType: field.type,
|
|
},
|
|
}),
|
|
),
|
|
});
|
|
});
|
|
}
|
|
|
|
// Filter out fields that have been removed or have been updated.
|
|
const filteredFields = existingFields.filter((field) => {
|
|
const isRemoved = removedFields.find((removedField) => removedField.id === field.id);
|
|
const isUpdated = persistedFields.find((persistedField) => persistedField.id === field.id);
|
|
|
|
return !isRemoved && !isUpdated;
|
|
});
|
|
|
|
return {
|
|
fields: [...filteredFields, ...persistedFields].map((field) =>
|
|
mapFieldToLegacyField(field, envelope),
|
|
),
|
|
};
|
|
};
|
|
|
|
/**
|
|
* If you change this you MUST update the `hasFieldBeenChanged` function.
|
|
*/
|
|
type FieldData = {
|
|
id?: number | null;
|
|
envelopeItemId: string;
|
|
type: FieldType;
|
|
recipientId: number;
|
|
pageNumber: number;
|
|
pageX: number;
|
|
pageY: number;
|
|
pageWidth: number;
|
|
pageHeight: number;
|
|
fieldMeta?: FieldMeta;
|
|
};
|
|
|
|
const hasFieldBeenChanged = (field: Field, newFieldData: FieldData) => {
|
|
const currentFieldMeta = field.fieldMeta || null;
|
|
const newFieldMeta = newFieldData.fieldMeta || null;
|
|
|
|
return (
|
|
field.envelopeItemId !== newFieldData.envelopeItemId ||
|
|
field.type !== newFieldData.type ||
|
|
field.page !== newFieldData.pageNumber ||
|
|
field.positionX.toNumber() !== newFieldData.pageX ||
|
|
field.positionY.toNumber() !== newFieldData.pageY ||
|
|
field.width.toNumber() !== newFieldData.pageWidth ||
|
|
field.height.toNumber() !== newFieldData.pageHeight ||
|
|
!isDeepEqual(currentFieldMeta, newFieldMeta)
|
|
);
|
|
};
|