feat: add envelopes (#2025)

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.
This commit is contained in:
David Nguyen
2025-10-14 21:56:36 +11:00
committed by GitHub
parent 7b17156e56
commit 7f09ba72f4
447 changed files with 33467 additions and 9622 deletions

View File

@ -1,126 +0,0 @@
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { TFieldAndMeta } from '@documenso/lib/types/field-meta';
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 { AppError, AppErrorCode } from '../../errors/app-error';
import { canRecipientFieldsBeModified } from '../../utils/recipients';
import { getDocumentWhereInput } from '../document/get-document-by-id';
export interface CreateDocumentFieldsOptions {
userId: number;
teamId: number;
documentId: number;
fields: (TFieldAndMeta & {
recipientId: number;
pageNumber: number;
pageX: number;
pageY: number;
width: number;
height: number;
})[];
requestMetadata: ApiRequestMetadata;
}
export const createDocumentFields = async ({
userId,
teamId,
documentId,
fields,
requestMetadata,
}: CreateDocumentFieldsOptions) => {
const { documentWhereInput } = await getDocumentWhereInput({
documentId,
userId,
teamId,
});
const document = await prisma.document.findFirst({
where: documentWhereInput,
include: {
recipients: true,
fields: true,
},
});
if (!document) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
});
}
if (document.completedAt) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Document already complete',
});
}
// Field validation.
const validatedFields = fields.map((field) => {
const recipient = document.recipients.find((recipient) => recipient.id === field.recipientId);
// Each field MUST have a recipient associated with it.
if (!recipient) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `Recipient ${field.recipientId} not found`,
});
}
// Check whether the recipient associated with the field can have new fields created.
if (!canRecipientFieldsBeModified(recipient, document.fields)) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message:
'Recipient type cannot have fields, or they have already interacted with the document.',
});
}
return {
...field,
recipientEmail: recipient.email,
};
});
const createdFields = await prisma.$transaction(async (tx) => {
return await Promise.all(
validatedFields.map(async (field) => {
const createdField = await tx.field.create({
data: {
type: field.type,
page: field.pageNumber,
positionX: field.pageX,
positionY: field.pageY,
width: field.width,
height: field.height,
customText: '',
inserted: false,
fieldMeta: field.fieldMeta,
documentId,
recipientId: field.recipientId,
},
});
// Handle field created audit log.
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_CREATED,
documentId,
metadata: requestMetadata,
data: {
fieldId: createdField.secondaryId,
fieldRecipientEmail: field.recipientEmail,
fieldRecipientId: createdField.recipientId,
fieldType: createdField.type,
},
}),
});
return createdField;
}),
);
});
return {
fields: createdFields,
};
};

View File

@ -0,0 +1,168 @@
import { EnvelopeType } from '@prisma/client';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { TFieldAndMeta } from '@documenso/lib/types/field-meta';
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 { 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 CreateEnvelopeFieldsOptions {
userId: number;
teamId: number;
id: EnvelopeIdOptions;
fields: (TFieldAndMeta & {
/**
* The ID of the item to insert the fields into.
*
* If blank, the first item will be used.
*/
envelopeItemId?: string;
recipientId: number;
pageNumber: number;
pageX: number;
pageY: number;
width: number;
height: number;
})[];
requestMetadata: ApiRequestMetadata;
}
export const createEnvelopeFields = async ({
userId,
teamId,
id,
fields,
requestMetadata,
}: CreateEnvelopeFieldsOptions) => {
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id,
type: null, // Null to allow any type of envelope.
userId,
teamId,
});
const envelope = await prisma.envelope.findFirst({
where: envelopeWhereInput,
include: {
recipients: true,
fields: true,
envelopeItems: {
select: {
id: true,
},
},
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope not found',
});
}
if (envelope.type === EnvelopeType.DOCUMENT && envelope.completedAt) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Envelope already complete',
});
}
const firstEnvelopeItem = envelope.envelopeItems[0];
if (!firstEnvelopeItem) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope item not found',
});
}
// Field validation.
const validatedFields = fields.map((field) => {
const recipient = envelope.recipients.find((recipient) => recipient.id === field.recipientId);
// The item to attach the fields to MUST belong to the document.
if (
field.envelopeItemId &&
!envelope.envelopeItems.find((envelopeItem) => envelopeItem.id === field.envelopeItemId)
) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Item to attach fields to must belong to the document',
});
}
// Each field MUST have a recipient associated with it.
if (!recipient) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `Recipient ${field.recipientId} not found`,
});
}
// Check whether the recipient associated with the field can have new fields created.
if (!canRecipientFieldsBeModified(recipient, envelope.fields)) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message:
'Recipient type cannot have fields, or they have already interacted with the document.',
});
}
return {
...field,
envelopeItemId: field.envelopeItemId || firstEnvelopeItem.id, // Fallback to first envelope item if no envelope item ID is provided.
recipientEmail: recipient.email,
};
});
const createdFields = await prisma.$transaction(async (tx) => {
const newlyCreatedFields = await tx.field.createManyAndReturn({
data: validatedFields.map((field) => ({
type: field.type,
page: field.pageNumber,
positionX: field.pageX,
positionY: field.pageY,
width: field.width,
height: field.height,
customText: '',
inserted: false,
fieldMeta: field.fieldMeta,
envelopeId: envelope.id,
envelopeItemId: field.envelopeItemId,
recipientId: field.recipientId,
})),
});
// Handle field created audit log.
if (envelope.type === EnvelopeType.DOCUMENT) {
await tx.documentAuditLog.createMany({
data: newlyCreatedFields.map((createdField) => {
const recipient = validatedFields.find(
(field) => field.recipientId === createdField.recipientId,
);
return createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_CREATED,
envelopeId: envelope.id,
metadata: requestMetadata,
data: {
fieldId: createdField.secondaryId,
fieldRecipientEmail: recipient?.recipientEmail || '',
fieldRecipientId: createdField.recipientId,
fieldType: createdField.type,
},
});
}),
});
}
return newlyCreatedFields;
});
return {
fields: createdFields.map((field) => mapFieldToLegacyField(field, envelope)),
};
};

View File

@ -1,136 +0,0 @@
import type { FieldType } from '@prisma/client';
import { match } from 'ts-pattern';
import { prisma } from '@documenso/prisma';
import {
ZCheckboxFieldMeta,
ZDropdownFieldMeta,
ZNumberFieldMeta,
ZRadioFieldMeta,
ZTextFieldMeta,
} from '../../types/field-meta';
import type { TFieldMetaSchema as FieldMeta } from '../../types/field-meta';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { getDocumentWhereInput } from '../document/get-document-by-id';
export type CreateFieldOptions = {
documentId: number;
userId: number;
teamId: number;
recipientId: number;
type: FieldType;
pageNumber: number;
pageX: number;
pageY: number;
pageWidth: number;
pageHeight: number;
fieldMeta?: FieldMeta;
requestMetadata?: RequestMetadata;
};
export const createField = async ({
documentId,
userId,
teamId,
recipientId,
type,
pageNumber,
pageX,
pageY,
pageWidth,
pageHeight,
fieldMeta,
requestMetadata,
}: CreateFieldOptions) => {
const { documentWhereInput, team } = await getDocumentWhereInput({
documentId,
userId,
teamId,
});
const document = await prisma.document.findFirst({
where: documentWhereInput,
select: {
id: true,
},
});
if (!document) {
throw new Error('Document not found');
}
const advancedField = ['NUMBER', 'RADIO', 'CHECKBOX', 'DROPDOWN', 'TEXT'].includes(type);
if (advancedField && !fieldMeta) {
throw new Error(
'Field meta is required for this type of field. Please provide the appropriate field meta object.',
);
}
if (fieldMeta && fieldMeta.type.toLowerCase() !== String(type).toLowerCase()) {
throw new Error('Field meta type does not match the field type');
}
const result = match(type)
.with('RADIO', () => ZRadioFieldMeta.safeParse(fieldMeta))
.with('CHECKBOX', () => ZCheckboxFieldMeta.safeParse(fieldMeta))
.with('DROPDOWN', () => ZDropdownFieldMeta.safeParse(fieldMeta))
.with('NUMBER', () => ZNumberFieldMeta.safeParse(fieldMeta))
.with('TEXT', () => ZTextFieldMeta.safeParse(fieldMeta))
.with('SIGNATURE', 'INITIALS', 'DATE', 'EMAIL', 'NAME', () => ({
success: true,
data: {},
}))
.with('FREE_SIGNATURE', () => ({
success: false,
error: 'FREE_SIGNATURE is not supported',
data: {},
}))
.exhaustive();
if (!result.success) {
throw new Error('Field meta parsing failed');
}
const field = await prisma.field.create({
data: {
documentId,
recipientId,
type,
page: pageNumber,
positionX: pageX,
positionY: pageY,
width: pageWidth,
height: pageHeight,
customText: '',
inserted: false,
fieldMeta: result.data,
},
include: {
recipient: true,
},
});
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: 'FIELD_CREATED',
documentId,
user: {
id: team.id,
email: team.name,
name: '',
},
data: {
fieldId: field.secondaryId,
fieldRecipientEmail: field.recipient?.email ?? '',
fieldRecipientId: recipientId,
fieldType: field.type,
},
requestMetadata,
}),
});
return field;
};

View File

@ -1,101 +0,0 @@
import type { FieldType } from '@prisma/client';
import type { TFieldMetaSchema } from '@documenso/lib/types/field-meta';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { canRecipientFieldsBeModified } from '../../utils/recipients';
import { buildTeamWhereQuery } from '../../utils/teams';
export interface CreateTemplateFieldsOptions {
userId: number;
teamId: number;
templateId: number;
fields: {
recipientId: number;
type: FieldType;
pageNumber: number;
pageX: number;
pageY: number;
width: number;
height: number;
fieldMeta?: TFieldMetaSchema;
}[];
}
export const createTemplateFields = async ({
userId,
teamId,
templateId,
fields,
}: CreateTemplateFieldsOptions) => {
const template = await prisma.template.findFirst({
where: {
id: templateId,
team: buildTeamWhereQuery({ teamId, userId }),
},
include: {
recipients: true,
fields: true,
},
});
if (!template) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'template not found',
});
}
// Field validation.
const validatedFields = fields.map((field) => {
const recipient = template.recipients.find((recipient) => recipient.id === field.recipientId);
// Each field MUST have a recipient associated with it.
if (!recipient) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `Recipient ${field.recipientId} not found`,
});
}
// Check whether the recipient associated with the field can have new fields created.
if (!canRecipientFieldsBeModified(recipient, template.fields)) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message:
'Recipient type cannot have fields, or they have already interacted with the template.',
});
}
return {
...field,
recipientEmail: recipient.email,
};
});
const createdFields = await prisma.$transaction(async (tx) => {
return await Promise.all(
validatedFields.map(async (field) => {
const createdField = await tx.field.create({
data: {
type: field.type,
page: field.pageNumber,
positionX: field.pageX,
positionY: field.pageY,
width: field.width,
height: field.height,
customText: '',
inserted: false,
fieldMeta: field.fieldMeta,
templateId,
recipientId: field.recipientId,
},
});
return createdField;
}),
);
});
return {
fields: createdFields,
};
};

View File

@ -1,3 +1,5 @@
import { EnvelopeType } from '@prisma/client';
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';
@ -5,7 +7,7 @@ import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { canRecipientFieldsBeModified } from '../../utils/recipients';
import { getDocumentWhereInput } from '../document/get-document-by-id';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export interface DeleteDocumentFieldOptions {
userId: number;
@ -19,7 +21,8 @@ export const deleteDocumentField = async ({
teamId,
fieldId,
requestMetadata,
}: DeleteDocumentFieldOptions): Promise<void> => {
}: DeleteDocumentFieldOptions) => {
// Unauthenticated check, we do the real check later.
const field = await prisma.field.findFirst({
where: {
id: fieldId,
@ -32,22 +35,18 @@ export const deleteDocumentField = async ({
});
}
const documentId = field.documentId;
if (!documentId) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Field does not belong to a document. Use delete template field instead.',
});
}
const { documentWhereInput } = await getDocumentWhereInput({
documentId,
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: {
type: 'envelopeId',
id: field.envelopeId,
},
type: EnvelopeType.DOCUMENT,
userId,
teamId,
});
const document = await prisma.document.findFirst({
where: documentWhereInput,
const envelope = await prisma.envelope.findUnique({
where: envelopeWhereInput,
include: {
recipients: {
where: {
@ -60,19 +59,19 @@ export const deleteDocumentField = async ({
},
});
if (!document) {
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
});
}
if (document.completedAt) {
if (envelope.completedAt) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Document already complete',
});
}
const recipient = document.recipients.find((recipient) => recipient.id === field.recipientId);
const recipient = envelope.recipients.find((recipient) => recipient.id === field.recipientId);
if (!recipient) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
@ -87,10 +86,11 @@ export const deleteDocumentField = async ({
});
}
await prisma.$transaction(async (tx) => {
return await prisma.$transaction(async (tx) => {
const deletedField = await tx.field.delete({
where: {
id: fieldId,
envelopeId: envelope.id,
},
});
@ -98,7 +98,7 @@ export const deleteDocumentField = async ({
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_DELETED,
documentId,
envelopeId: envelope.id,
metadata: requestMetadata,
data: {
fieldId: deletedField.secondaryId,
@ -108,5 +108,7 @@ export const deleteDocumentField = async ({
},
}),
});
return deletedField;
});
};

View File

@ -1,78 +0,0 @@
import type { Team } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { buildTeamWhereQuery } from '../../utils/teams';
export type DeleteFieldOptions = {
fieldId: number;
documentId: number;
userId: number;
teamId: number;
requestMetadata?: RequestMetadata;
};
export const deleteField = async ({
fieldId,
userId,
teamId,
documentId,
requestMetadata,
}: DeleteFieldOptions) => {
const field = await prisma.field.delete({
where: {
id: fieldId,
document: {
id: documentId,
team: buildTeamWhereQuery({ teamId, userId }),
},
},
include: {
recipient: true,
},
});
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
select: {
id: true,
name: true,
email: true,
},
});
let team: Team | null = null;
if (teamId) {
team = await prisma.team.findFirstOrThrow({
where: {
id: teamId,
},
});
}
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: 'FIELD_DELETED',
documentId,
user: {
id: team?.id ?? user.id,
email: team?.name ?? user.email,
name: team ? '' : user.name,
},
data: {
fieldId: field.secondaryId,
fieldRecipientEmail: field.recipient?.email ?? '',
fieldRecipientId: field.recipientId ?? -1,
fieldType: field.type,
},
requestMetadata,
}),
});
return field;
};

View File

@ -1,7 +1,10 @@
import { EnvelopeType } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export interface DeleteTemplateFieldOptions {
userId: number;
@ -17,21 +20,34 @@ export const deleteTemplateField = async ({
const field = await prisma.field.findFirst({
where: {
id: fieldId,
template: {
envelope: {
type: EnvelopeType.TEMPLATE,
team: buildTeamWhereQuery({ teamId, userId }),
},
},
});
if (!field || !field.templateId) {
if (!field) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Field not found',
});
}
// Additional validation to check visibility.
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: {
type: 'envelopeId',
id: field.envelopeId,
},
type: EnvelopeType.TEMPLATE,
userId,
teamId,
});
await prisma.field.delete({
where: {
id: fieldId,
id: field.id,
envelope: envelopeWhereInput,
},
});
};

View File

@ -1,4 +1,4 @@
import { SigningStatus } from '@prisma/client';
import { EnvelopeType, SigningStatus } from '@prisma/client';
import { prisma } from '@documenso/prisma';
@ -6,10 +6,12 @@ export type GetCompletedFieldsForTokenOptions = {
token: string;
};
// Note: You many need to filter this on a per envelope item ID basis.
export const getCompletedFieldsForToken = async ({ token }: GetCompletedFieldsForTokenOptions) => {
return await prisma.field.findMany({
where: {
document: {
envelope: {
type: EnvelopeType.DOCUMENT,
recipients: {
some: {
token,

View File

@ -1,53 +1,61 @@
import type { EnvelopeType } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { mapFieldToLegacyField } from '../../utils/fields';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export type GetFieldByIdOptions = {
userId: number;
teamId: number;
fieldId: number;
envelopeType: EnvelopeType;
};
export const getFieldById = async ({ userId, teamId, fieldId }: GetFieldByIdOptions) => {
export const getFieldById = async ({
userId,
teamId,
fieldId,
envelopeType,
}: GetFieldByIdOptions) => {
const field = await prisma.field.findFirst({
where: {
id: fieldId,
},
include: {
document: {
select: {
teamId: true,
},
},
template: {
select: {
teamId: true,
},
envelope: {
type: envelopeType,
team: buildTeamWhereQuery({ teamId, userId }),
},
},
});
const foundTeamId = field?.document?.teamId || field?.template?.teamId;
if (!field || !foundTeamId || foundTeamId !== teamId) {
if (!field) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Field not found',
});
}
const team = await prisma.team.findUnique({
where: buildTeamWhereQuery({
teamId: foundTeamId,
userId,
}),
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: {
type: 'envelopeId',
id: field.envelopeId,
},
type: envelopeType,
userId,
teamId,
});
if (!team) {
// Additional validation to check visibility.
const envelope = await prisma.envelope.findUnique({
where: envelopeWhereInput,
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Field not found',
});
}
return field;
return mapFieldToLegacyField(field, envelope);
};

View File

@ -1,41 +0,0 @@
import { prisma } from '@documenso/prisma';
import { buildTeamWhereQuery } from '../../utils/teams';
export interface GetFieldsForDocumentOptions {
documentId: number;
userId: number;
teamId: number;
}
export type DocumentField = Awaited<ReturnType<typeof getFieldsForDocument>>[number];
export const getFieldsForDocument = async ({
documentId,
userId,
teamId,
}: GetFieldsForDocumentOptions) => {
const fields = await prisma.field.findMany({
where: {
document: {
id: documentId,
team: buildTeamWhereQuery({ teamId, userId }),
},
},
include: {
signature: true,
recipient: {
select: {
name: true,
email: true,
signingStatus: true,
},
},
},
orderBy: {
id: 'asc',
},
});
return fields;
};

View File

@ -1,4 +1,4 @@
import { FieldType, RecipientRole, SigningStatus } from '@prisma/client';
import { EnvelopeType, FieldType, RecipientRole, SigningStatus } from '@prisma/client';
import { prisma } from '@documenso/prisma';
@ -6,6 +6,7 @@ export type GetFieldsForTokenOptions = {
token: string;
};
// Note: You many need to filter this on a per envelope item ID basis.
export const getFieldsForToken = async ({ token }: GetFieldsForTokenOptions) => {
if (!token) {
throw new Error('Missing token');
@ -35,7 +36,10 @@ export const getFieldsForToken = async ({ token }: GetFieldsForTokenOptions) =>
gte: recipient.signingOrder ?? 0,
},
},
documentId: recipient.documentId,
envelope: {
id: recipient.envelopeId,
type: EnvelopeType.DOCUMENT,
},
},
{
recipientId: recipient.id,

View File

@ -41,19 +41,19 @@ export const removeSignedFieldWithToken = async ({
},
},
include: {
document: true,
envelope: true,
recipient: true,
},
});
const { document } = field;
const { envelope } = field;
if (!document) {
if (!envelope) {
throw new Error(`Document not found for field ${field.id}`);
}
if (document.status !== DocumentStatus.PENDING) {
throw new Error(`Document ${document.id} must be pending`);
if (envelope.status !== DocumentStatus.PENDING) {
throw new Error(`Document ${envelope.id} must be pending`);
}
if (
@ -89,7 +89,7 @@ export const removeSignedFieldWithToken = async ({
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_UNINSERTED,
documentId: document.id,
envelopeId: envelope.id,
user: {
name: recipient.name,
email: recipient.email,

View File

@ -1,5 +1,4 @@
import type { Field } from '@prisma/client';
import { FieldType } from '@prisma/client';
import { EnvelopeType, type Field, FieldType } from '@prisma/client';
import { isDeepEqual } from 'remeda';
import { validateCheckboxField } from '@documenso/lib/advanced-fields-validation/validate-checkbox';
@ -25,13 +24,15 @@ import {
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 { getDocumentWhereInput } from '../document/get-document-by-id';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export interface SetFieldsForDocumentOptions {
userId: number;
teamId: number;
documentId: number;
id: EnvelopeIdOptions;
fields: FieldData[];
requestMetadata: ApiRequestMetadata;
}
@ -39,43 +40,47 @@ export interface SetFieldsForDocumentOptions {
export const setFieldsForDocument = async ({
userId,
teamId,
documentId,
id,
fields,
requestMetadata,
}: SetFieldsForDocumentOptions) => {
const { documentWhereInput } = await getDocumentWhereInput({
documentId,
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id,
type: EnvelopeType.DOCUMENT,
userId,
teamId,
});
const document = await prisma.document.findFirst({
where: documentWhereInput,
const envelope = await prisma.envelope.findFirst({
where: envelopeWhereInput,
include: {
recipients: true,
envelopeItems: {
select: {
id: true,
},
},
fields: {
include: {
recipient: true,
},
},
},
});
if (!document) {
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
});
}
if (document.completedAt) {
if (envelope.completedAt) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Document already complete',
});
}
const existingFields = await prisma.field.findMany({
where: {
documentId,
},
include: {
recipient: true,
},
});
const existingFields = envelope.fields;
const removedFields = existingFields.filter(
(existingField) => !fields.find((field) => field.id === existingField.id),
@ -84,7 +89,18 @@ export const setFieldsForDocument = async ({
const linkedFields = fields.map((field) => {
const existing = existingFields.find((existingField) => existingField.id === field.id);
const recipient = document.recipients.find((recipient) => recipient.id === field.recipientId);
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) {
@ -105,6 +121,14 @@ export const setFieldsForDocument = async ({
});
}
// 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,
@ -115,7 +139,7 @@ export const setFieldsForDocument = async ({
const persistedFields = await prisma.$transaction(async (tx) => {
return await Promise.all(
linkedFields.map(async (field) => {
const fieldSignerEmail = field.signerEmail.toLowerCase();
const fieldSignerEmail = field._recipient.email.toLowerCase();
const parsedFieldMeta = field.fieldMeta
? ZFieldMetaSchema.parse(field.fieldMeta)
@ -197,7 +221,8 @@ export const setFieldsForDocument = async ({
const upsertedField = await tx.field.upsert({
where: {
id: field._persisted?.id ?? -1,
documentId,
envelopeId: envelope.id,
envelopeItemId: field.envelopeItemId,
},
update: {
page: field.pageNumber,
@ -217,15 +242,21 @@ export const setFieldsForDocument = async ({
customText: '',
inserted: false,
fieldMeta: parsedFieldMeta,
document: {
envelope: {
connect: {
id: documentId,
id: envelope.id,
},
},
envelopeItem: {
connect: {
id: field.envelopeItemId,
envelopeId: envelope.id,
},
},
recipient: {
connect: {
id: field.recipientId,
documentId,
id: field._recipient.id,
envelopeId: envelope.id,
},
},
},
@ -249,7 +280,7 @@ export const setFieldsForDocument = async ({
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_UPDATED,
documentId: documentId,
envelopeId: envelope.id,
metadata: requestMetadata,
data: {
changes,
@ -264,7 +295,7 @@ export const setFieldsForDocument = async ({
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_CREATED,
documentId: documentId,
envelopeId: envelope.id,
metadata: requestMetadata,
data: {
...baseAuditLog,
@ -292,7 +323,7 @@ export const setFieldsForDocument = async ({
data: removedFields.map((field) =>
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_DELETED,
documentId: documentId,
envelopeId: envelope.id,
metadata: requestMetadata,
data: {
fieldId: field.secondaryId,
@ -315,7 +346,9 @@ export const setFieldsForDocument = async ({
});
return {
fields: [...filteredFields, ...persistedFields],
fields: [...filteredFields, ...persistedFields].map((field) =>
mapFieldToLegacyField(field, envelope),
),
};
};
@ -324,8 +357,8 @@ export const setFieldsForDocument = async ({
*/
type FieldData = {
id?: number | null;
envelopeItemId: string;
type: FieldType;
signerEmail: string;
recipientId: number;
pageNumber: number;
pageX: number;
@ -340,6 +373,7 @@ const hasFieldBeenChanged = (field: Field, newFieldData: FieldData) => {
const newFieldMeta = newFieldData.fieldMeta || null;
return (
field.envelopeItemId !== newFieldData.envelopeItemId ||
field.type !== newFieldData.type ||
field.page !== newFieldData.pageNumber ||
field.positionX.toNumber() !== newFieldData.pageX ||

View File

@ -1,4 +1,4 @@
import { FieldType } from '@prisma/client';
import { EnvelopeType, FieldType } from '@prisma/client';
import { validateCheckboxField } from '@documenso/lib/advanced-fields-validation/validate-checkbox';
import { validateDropdownField } from '@documenso/lib/advanced-fields-validation/validate-dropdown';
@ -16,16 +16,19 @@ import {
} from '@documenso/lib/types/field-meta';
import { prisma } from '@documenso/prisma';
import { buildTeamWhereQuery } from '../../utils/teams';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { mapFieldToLegacyField } from '../../utils/fields';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export type SetFieldsForTemplateOptions = {
userId: number;
teamId: number;
templateId: number;
id: EnvelopeIdOptions;
fields: {
id?: number | null;
envelopeItemId: string;
type: FieldType;
signerEmail: string;
recipientId: number;
pageNumber: number;
pageX: number;
@ -39,28 +42,40 @@ export type SetFieldsForTemplateOptions = {
export const setFieldsForTemplate = async ({
userId,
teamId,
templateId,
id,
fields,
}: SetFieldsForTemplateOptions) => {
const template = await prisma.template.findFirst({
where: {
id: templateId,
team: buildTeamWhereQuery({ teamId, userId }),
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id,
type: EnvelopeType.TEMPLATE,
userId,
teamId,
});
const envelope = await prisma.envelope.findFirst({
where: envelopeWhereInput,
include: {
recipients: true,
envelopeItems: {
select: {
id: true,
},
},
fields: {
include: {
recipient: true,
},
},
},
});
if (!template) {
throw new Error('Template not found');
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
});
}
const existingFields = await prisma.field.findMany({
where: {
templateId,
},
include: {
recipient: true,
},
});
const existingFields = envelope.fields;
const removedFields = existingFields.filter(
(existingField) => !fields.find((field) => field.id === existingField.id),
@ -69,9 +84,30 @@ export const setFieldsForTemplate = async ({
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}`,
});
}
return {
...field,
_persisted: existing,
_recipient: recipient,
};
});
@ -143,7 +179,8 @@ export const setFieldsForTemplate = async ({
return prisma.field.upsert({
where: {
id: field._persisted?.id ?? -1,
templateId,
envelopeId: envelope.id,
envelopeItemId: field.envelopeItemId,
},
update: {
page: field.pageNumber,
@ -163,15 +200,21 @@ export const setFieldsForTemplate = async ({
customText: '',
inserted: false,
fieldMeta: parsedFieldMeta,
template: {
envelope: {
connect: {
id: templateId,
id: envelope.id,
},
},
envelopeItem: {
connect: {
id: field.envelopeItemId,
envelopeId: envelope.id,
},
},
recipient: {
connect: {
id: field.recipientId,
templateId,
id: field._recipient.id,
envelopeId: envelope.id,
},
},
},
@ -198,6 +241,8 @@ export const setFieldsForTemplate = async ({
});
return {
fields: [...filteredFields, ...persistedFields],
fields: [...filteredFields, ...persistedFields].map((field) =>
mapFieldToLegacyField(field, envelope),
),
};
};

View File

@ -81,7 +81,7 @@ export const signFieldWithToken = async ({
},
},
include: {
document: {
envelope: {
include: {
recipients: true,
},
@ -90,9 +90,9 @@ export const signFieldWithToken = async ({
},
});
const { document } = field;
const { envelope } = field;
if (!document) {
if (!envelope) {
throw new Error(`Document not found for field ${field.id}`);
}
@ -100,12 +100,12 @@ export const signFieldWithToken = async ({
throw new Error(`Recipient not found for field ${field.id}`);
}
if (document.deletedAt) {
throw new Error(`Document ${document.id} has been deleted`);
if (envelope.deletedAt) {
throw new Error(`Document ${envelope.id} has been deleted`);
}
if (document.status !== DocumentStatus.PENDING) {
throw new Error(`Document ${document.id} must be pending for signing`);
if (envelope.status !== DocumentStatus.PENDING) {
throw new Error(`Document ${envelope.id} must be pending for signing`);
}
if (
@ -172,7 +172,7 @@ export const signFieldWithToken = async ({
}
const derivedRecipientActionAuth = await validateFieldAuth({
documentAuthOptions: document.authOptions,
documentAuthOptions: envelope.authOptions,
recipient,
field,
userId,
@ -181,7 +181,9 @@ export const signFieldWithToken = async ({
const documentMeta = await prisma.documentMeta.findFirst({
where: {
documentId: document.id,
envelope: {
id: envelope.id,
},
},
});
@ -272,7 +274,7 @@ export const signFieldWithToken = async ({
assistant && field.recipientId !== assistant.id
? DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_PREFILLED
: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED,
documentId: document.id,
envelopeId: envelope.id,
user: {
email: assistant?.email ?? recipient.email,
name: assistant?.name ?? recipient.name,

View File

@ -1,4 +1,4 @@
import type { FieldType } from '@prisma/client';
import { EnvelopeType, type FieldType } from '@prisma/client';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { TFieldMetaSchema } from '@documenso/lib/types/field-meta';
@ -10,8 +10,9 @@ import {
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { mapFieldToLegacyField } from '../../utils/fields';
import { canRecipientFieldsBeModified } from '../../utils/recipients';
import { getDocumentWhereInput } from '../document/get-document-by-id';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export interface UpdateDocumentFieldsOptions {
userId: number;
@ -37,34 +38,38 @@ export const updateDocumentFields = async ({
fields,
requestMetadata,
}: UpdateDocumentFieldsOptions) => {
const { documentWhereInput } = await getDocumentWhereInput({
documentId,
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: {
type: 'documentId',
id: documentId,
},
type: EnvelopeType.DOCUMENT,
userId,
teamId,
});
const document = await prisma.document.findFirst({
where: documentWhereInput,
const envelope = await prisma.envelope.findFirst({
where: envelopeWhereInput,
include: {
recipients: true,
fields: true,
},
});
if (!document) {
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
});
}
if (document.completedAt) {
if (envelope.completedAt) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Document already complete',
});
}
const fieldsToUpdate = fields.map((field) => {
const originalField = document.fields.find((existingField) => existingField.id === field.id);
const originalField = envelope.fields.find((existingField) => existingField.id === field.id);
if (!originalField) {
throw new AppError(AppErrorCode.NOT_FOUND, {
@ -72,7 +77,7 @@ export const updateDocumentFields = async ({
});
}
const recipient = document.recipients.find(
const recipient = envelope.recipients.find(
(recipient) => recipient.id === originalField.recipientId,
);
@ -84,7 +89,7 @@ export const updateDocumentFields = async ({
}
// Check whether the recipient associated with the field can be modified.
if (!canRecipientFieldsBeModified(recipient, document.fields)) {
if (!canRecipientFieldsBeModified(recipient, envelope.fields)) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message:
'Cannot modify a field where the recipient has already interacted with the document',
@ -123,7 +128,7 @@ export const updateDocumentFields = async ({
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_UPDATED,
documentId: documentId,
envelopeId: envelope.id,
metadata: requestMetadata,
data: {
fieldId: updatedField.secondaryId,
@ -142,6 +147,6 @@ export const updateDocumentFields = async ({
});
return {
fields: updatedFields,
fields: updatedFields.map((field) => mapFieldToLegacyField(field, envelope)),
};
};

View File

@ -1,119 +0,0 @@
import type { FieldType, Team } from '@prisma/client';
import { type TFieldMetaSchema as FieldMeta } from '@documenso/lib/types/field-meta';
import { prisma } from '@documenso/prisma';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData, diffFieldChanges } from '../../utils/document-audit-logs';
import { buildTeamWhereQuery } from '../../utils/teams';
export type UpdateFieldOptions = {
fieldId: number;
documentId: number;
userId: number;
teamId: number;
recipientId?: number;
type?: FieldType;
pageNumber?: number;
pageX?: number;
pageY?: number;
pageWidth?: number;
pageHeight?: number;
requestMetadata?: RequestMetadata;
fieldMeta?: FieldMeta;
};
export const updateField = async ({
fieldId,
documentId,
userId,
teamId,
recipientId,
type,
pageNumber,
pageX,
pageY,
pageWidth,
pageHeight,
requestMetadata,
fieldMeta,
}: UpdateFieldOptions) => {
if (type === 'FREE_SIGNATURE') {
throw new Error('Cannot update a FREE_SIGNATURE field');
}
const oldField = await prisma.field.findFirstOrThrow({
where: {
id: fieldId,
document: {
id: documentId,
team: buildTeamWhereQuery({ teamId, userId }),
},
},
});
const field = prisma.$transaction(async (tx) => {
const updatedField = await tx.field.update({
where: {
id: fieldId,
},
data: {
recipientId,
type,
page: pageNumber,
positionX: pageX,
positionY: pageY,
width: pageWidth,
height: pageHeight,
fieldMeta,
},
include: {
recipient: true,
},
});
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
select: {
id: true,
name: true,
email: true,
},
});
let team: Team | null = null;
if (teamId) {
team = await prisma.team.findFirst({
where: buildTeamWhereQuery({ teamId, userId }),
});
}
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_UPDATED,
documentId,
user: {
id: team?.id ?? user.id,
email: team?.name ?? user.email,
name: team ? '' : user.name,
},
data: {
fieldId: updatedField.secondaryId,
fieldRecipientEmail: updatedField.recipient?.email ?? '',
fieldRecipientId: recipientId ?? -1,
fieldType: updatedField.type,
changes: diffFieldChanges(oldField, updatedField),
},
requestMetadata,
}),
});
return updatedField;
});
return field;
};

View File

@ -1,11 +1,12 @@
import type { FieldType } from '@prisma/client';
import { EnvelopeType, type FieldType } from '@prisma/client';
import type { TFieldMetaSchema } from '@documenso/lib/types/field-meta';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { mapFieldToLegacyField } from '../../utils/fields';
import { canRecipientFieldsBeModified } from '../../utils/recipients';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export interface UpdateTemplateFieldsOptions {
userId: number;
@ -29,25 +30,32 @@ export const updateTemplateFields = async ({
templateId,
fields,
}: UpdateTemplateFieldsOptions) => {
const template = await prisma.template.findFirst({
where: {
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: {
type: 'templateId',
id: templateId,
team: buildTeamWhereQuery({ teamId, userId }),
},
type: EnvelopeType.TEMPLATE,
userId,
teamId,
});
const envelope = await prisma.envelope.findFirst({
where: envelopeWhereInput,
include: {
recipients: true,
fields: true,
},
});
if (!template) {
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
});
}
const fieldsToUpdate = fields.map((field) => {
const originalField = template.fields.find((existingField) => existingField.id === field.id);
const originalField = envelope.fields.find((existingField) => existingField.id === field.id);
if (!originalField) {
throw new AppError(AppErrorCode.NOT_FOUND, {
@ -55,7 +63,7 @@ export const updateTemplateFields = async ({
});
}
const recipient = template.recipients.find(
const recipient = envelope.recipients.find(
(recipient) => recipient.id === originalField.recipientId,
);
@ -67,7 +75,7 @@ export const updateTemplateFields = async ({
}
// Check whether the recipient associated with the field can be modified.
if (!canRecipientFieldsBeModified(recipient, template.fields)) {
if (!canRecipientFieldsBeModified(recipient, envelope.fields)) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message:
'Cannot modify a field where the recipient has already interacted with the document',
@ -103,6 +111,6 @@ export const updateTemplateFields = async ({
});
return {
fields: updatedFields,
fields: updatedFields.map((field) => mapFieldToLegacyField(field, envelope)),
};
};