feat: polish envelopes (#2090)

## Description

The rest of the owl
This commit is contained in:
David Nguyen
2025-10-24 16:22:06 +11:00
committed by GitHub
parent 88836404d1
commit 03eb6af69a
141 changed files with 5171 additions and 2402 deletions

View File

@ -23,7 +23,6 @@ export const createEnvelopeItemsRoute = authenticatedProcedure
},
});
// Todo: Envelopes - What to do about "normalizing"?
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: {
type: 'envelopeId',
@ -43,6 +42,15 @@ export const createEnvelopeItemsRoute = authenticatedProcedure
order: 'asc',
},
},
team: {
select: {
organisation: {
select: {
organisationClaim: true,
},
},
},
},
},
});
@ -58,7 +66,17 @@ export const createEnvelopeItemsRoute = authenticatedProcedure
});
}
// Todo: Envelopes - Limit amount of items that can be created.
const organisationClaim = envelope.team.organisation.organisationClaim;
const remainingEnvelopeItems =
organisationClaim.envelopeItemCount - envelope.envelopeItems.length - items.length;
if (remainingEnvelopeItems < 0) {
throw new AppError('ENVELOPE_ITEM_LIMIT_EXCEEDED', {
message: `You cannot upload more than ${organisationClaim.envelopeItemCount} envelope items`,
statusCode: 400,
});
}
const foundDocumentData = await prisma.documentData.findMany({
where: {
@ -93,7 +111,7 @@ export const createEnvelopeItemsRoute = authenticatedProcedure
envelope.envelopeItems[envelope.envelopeItems.length - 1]?.order ?? 1;
const result = await prisma.envelopeItem.createManyAndReturn({
data: items.map((item, index) => ({
data: items.map((item) => ({
id: prefixedId('envelope_item'),
envelopeId,
title: item.title,

View File

@ -33,8 +33,10 @@ export const createEnvelopeRoute = authenticatedProcedure
},
});
// Todo: Envelopes - Put the claims for number of items into this.
const { remaining } = await getServerLimits({ userId: user.id, teamId });
const { remaining, maximumEnvelopeItemCount } = await getServerLimits({
userId: user.id,
teamId,
});
if (remaining.documents <= 0) {
throw new AppError(AppErrorCode.LIMIT_EXCEEDED, {
@ -43,6 +45,13 @@ export const createEnvelopeRoute = authenticatedProcedure
});
}
if (items.length > maximumEnvelopeItemCount) {
throw new AppError('ENVELOPE_ITEM_LIMIT_EXCEEDED', {
message: `You cannot upload more than ${maximumEnvelopeItemCount} envelope items per envelope`,
statusCode: 400,
});
}
const envelope = await createEnvelope({
userId: user.id,
teamId,

View File

@ -52,13 +52,29 @@ export const deleteEnvelopeItemRoute = authenticatedProcedure
});
}
await prisma.envelopeItem.delete({
const deletedEnvelopeItem = await prisma.envelopeItem.delete({
where: {
id: envelopeItemId,
envelopeId: envelope.id,
},
select: {
documentData: {
select: {
id: true,
},
},
},
});
// Todo: Envelopes - Audit logs?
// Todo: Envelopes - Delete the document data as well?
// Todo: Envelopes [ASK] - Should we delete the document data?
await prisma.documentData.delete({
where: {
id: deletedEnvelopeItem.documentData.id,
envelopeItem: {
is: null,
},
},
});
// Todo: Envelope [AUDIT_LOGS]
});

View File

@ -0,0 +1,119 @@
import { EnvelopeType } from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { prisma } from '@documenso/prisma';
import { maybeAuthenticatedProcedure } from '../trpc';
import {
ZGetEnvelopeItemsByTokenRequestSchema,
ZGetEnvelopeItemsByTokenResponseSchema,
} from './get-envelope-items-by-token.types';
// Not intended for V2 API usage.
// NOTE: THIS IS A PUBLIC PROCEDURE
export const getEnvelopeItemsByTokenRoute = maybeAuthenticatedProcedure
.input(ZGetEnvelopeItemsByTokenRequestSchema)
.output(ZGetEnvelopeItemsByTokenResponseSchema)
.query(async ({ input, ctx }) => {
const { teamId, user } = ctx;
const { envelopeId, access } = input;
ctx.logger.info({
input: {
envelopeId,
access,
},
});
if (access.type === 'user') {
if (!user || !teamId) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'User not found',
});
}
return await handleGetEnvelopeItemsByUser({ envelopeId, userId: user.id, teamId });
}
return await handleGetEnvelopeItemsByToken({ envelopeId, token: access.token });
});
const handleGetEnvelopeItemsByToken = async ({
envelopeId,
token,
}: {
envelopeId: string;
token: string;
}) => {
const envelope = await prisma.envelope.findFirst({
where: {
id: envelopeId,
type: EnvelopeType.DOCUMENT, // You cannot get template envelope items by token.
recipients: {
some: {
token,
},
},
},
include: {
envelopeItems: {
include: {
documentData: true,
},
},
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope could not be found',
});
}
return {
envelopeItems: envelope.envelopeItems,
};
};
const handleGetEnvelopeItemsByUser = async ({
envelopeId,
userId,
teamId,
}: {
envelopeId: string;
userId: number;
teamId: number;
}) => {
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: {
type: 'envelopeId',
id: envelopeId,
},
type: null,
userId,
teamId,
});
const envelope = await prisma.envelope.findUnique({
where: envelopeWhereInput,
include: {
envelopeItems: {
include: {
documentData: true,
},
},
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope could not be found',
});
}
return {
envelopeItems: envelope.envelopeItems,
};
};

View File

@ -0,0 +1,34 @@
import { z } from 'zod';
import DocumentDataSchema from '@documenso/prisma/generated/zod/modelSchema/DocumentDataSchema';
import EnvelopeItemSchema from '@documenso/prisma/generated/zod/modelSchema/EnvelopeItemSchema';
export const ZGetEnvelopeItemsByTokenRequestSchema = z.object({
envelopeId: z.string(),
access: z.discriminatedUnion('type', [
z.object({
type: z.literal('recipient'),
token: z.string(),
}),
z.object({
type: z.literal('user'),
}),
]),
});
export const ZGetEnvelopeItemsByTokenResponseSchema = z.object({
envelopeItems: EnvelopeItemSchema.pick({
id: true,
title: true,
order: true,
})
.extend({
documentData: DocumentDataSchema.pick({
type: true,
id: true,
data: true,
initialData: true,
}),
})
.array(),
});

View File

@ -0,0 +1,55 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
import {
ZGetEnvelopeItemsRequestSchema,
ZGetEnvelopeItemsResponseSchema,
} from './get-envelope-items.types';
// Not intended for V2 API usage.
export const getEnvelopeItemsRoute = authenticatedProcedure
.input(ZGetEnvelopeItemsRequestSchema)
.output(ZGetEnvelopeItemsResponseSchema)
.query(async ({ input, ctx }) => {
const { teamId, user } = ctx;
const { envelopeId } = input;
ctx.logger.info({
input: {
envelopeId,
},
});
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: {
type: 'envelopeId',
id: envelopeId,
},
type: null,
userId: user.id,
teamId,
});
const envelope = await prisma.envelope.findUnique({
where: envelopeWhereInput,
include: {
envelopeItems: {
include: {
documentData: true,
},
},
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope could not be found',
});
}
return {
envelopeItems: envelope.envelopeItems,
};
});

View File

@ -0,0 +1,25 @@
import { z } from 'zod';
import DocumentDataSchema from '@documenso/prisma/generated/zod/modelSchema/DocumentDataSchema';
import EnvelopeItemSchema from '@documenso/prisma/generated/zod/modelSchema/EnvelopeItemSchema';
export const ZGetEnvelopeItemsRequestSchema = z.object({
envelopeId: z.string(),
});
export const ZGetEnvelopeItemsResponseSchema = z.object({
envelopeItems: EnvelopeItemSchema.pick({
id: true,
title: true,
order: true,
})
.extend({
documentData: DocumentDataSchema.pick({
type: true,
id: true,
data: true,
initialData: true,
}),
})
.array(),
});

View File

@ -10,6 +10,8 @@ import { deleteEnvelopeItemRoute } from './delete-envelope-item';
import { distributeEnvelopeRoute } from './distribute-envelope';
import { duplicateEnvelopeRoute } from './duplicate-envelope';
import { getEnvelopeRoute } from './get-envelope';
import { getEnvelopeItemsRoute } from './get-envelope-items';
import { getEnvelopeItemsByTokenRoute } from './get-envelope-items-by-token';
import { redistributeEnvelopeRoute } from './redistribute-envelope';
import { setEnvelopeFieldsRoute } from './set-envelope-fields';
import { setEnvelopeRecipientsRoute } from './set-envelope-recipients';
@ -28,6 +30,8 @@ export const envelopeRouter = router({
// share: shareEnvelopeRoute,
item: {
getMany: getEnvelopeItemsRoute,
getManyByToken: getEnvelopeItemsByTokenRoute,
createMany: createEnvelopeItemsRoute,
updateMany: updateEnvelopeItemsRoute,
delete: deleteEnvelopeItemRoute,

View File

@ -1,26 +1,12 @@
import { DocumentStatus, FieldType, RecipientRole, SigningStatus } from '@prisma/client';
import { DateTime } from 'luxon';
import { P, match } from 'ts-pattern';
import { z } from 'zod';
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 { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import { isBase64Image } from '@documenso/lib/constants/signatures';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { validateFieldAuth } from '@documenso/lib/server-only/document/validate-field-auth';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import {
ZCheckboxFieldMeta,
ZDropdownFieldMeta,
ZNumberFieldMeta,
ZRadioFieldMeta,
ZTextFieldMeta,
} from '@documenso/lib/types/field-meta';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { extractFieldInsertionValues } from '@documenso/lib/utils/envelope-signing';
import { prisma } from '@documenso/prisma';
import { procedure } from '../trpc';
@ -53,21 +39,21 @@ export const signEnvelopeFieldRoute = procedure
throw new AppError(AppErrorCode.NOT_FOUND);
}
const field = await prisma.field.findFirstOrThrow({
const field = await prisma.field.findFirst({
where: {
id: fieldId,
recipient: {
...(recipient.role !== RecipientRole.ASSISTANT
...(recipient.role === RecipientRole.ASSISTANT
? {
id: recipient.id,
}
: {
signingStatus: {
not: SigningStatus.SIGNED,
},
signingOrder: {
gte: recipient.signingOrder ?? 0,
},
}
: {
id: recipient.id,
}),
},
},
@ -82,21 +68,31 @@ export const signEnvelopeFieldRoute = procedure
},
});
const { envelope } = field;
const { documentMeta } = envelope;
if (!envelope || !recipient) {
if (!field) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: `Document not found for field ${field.id}`,
message: `Field ${fieldId} not found`,
});
}
const { envelope } = field;
const { documentMeta } = envelope;
if (envelope.internalVersion !== 2) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: `Envelope ${envelope.id} is not a version 2 envelope`,
});
}
if (
field.type === FieldType.SIGNATURE &&
recipient.id !== field.recipientId &&
recipient.role === RecipientRole.ASSISTANT
) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `Assistant recipients cannot sign signature fields`,
});
}
if (fieldValue.type !== field.type) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Selected values do not match the field values',
@ -124,7 +120,6 @@ export const signEnvelopeFieldRoute = procedure
});
}
// Todo: Envelopes - Need to auto insert read only fields during sealing.
if (field.fieldMeta?.readOnly) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `Field ${fieldId} is read only`,
@ -136,233 +131,7 @@ export const signEnvelopeFieldRoute = procedure
throw new Error(`Field ${fieldId} has no recipientId`);
}
let signatureImageAsBase64: string | null = null;
let typedSignature: string | null = null;
const insertionValues: { customText: string; inserted: boolean } = match(fieldValue)
.with({ type: FieldType.EMAIL }, (fieldValue) => {
const parsedEmailValue = z.string().email().nullable().safeParse(fieldValue.value);
if (!parsedEmailValue.success) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Invalid email',
});
}
if (parsedEmailValue.data === null) {
return {
customText: '',
inserted: false,
};
}
return {
customText: parsedEmailValue.data,
inserted: true,
};
})
.with({ type: P.union(FieldType.NAME, FieldType.INITIALS) }, (fieldValue) => {
const parsedGenericStringValue = z.string().min(1).nullable().safeParse(fieldValue.value);
if (!parsedGenericStringValue.success) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Value is required',
});
}
if (parsedGenericStringValue.data === null) {
return {
customText: '',
inserted: false,
};
}
return {
customText: parsedGenericStringValue.data,
inserted: true,
};
})
.with({ type: FieldType.DATE }, (fieldValue) => {
if (!fieldValue.value) {
return {
customText: '',
inserted: false,
};
}
return {
customText: DateTime.now()
.setZone(documentMeta.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE)
.toFormat(documentMeta.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT),
inserted: true,
};
})
.with({ type: FieldType.NUMBER }, (fieldValue) => {
if (!fieldValue.value) {
return {
customText: '',
inserted: false,
};
}
const numberFieldParsedMeta = ZNumberFieldMeta.parse(field.fieldMeta);
const errors = validateNumberField(
fieldValue.value.toString(),
numberFieldParsedMeta,
true,
);
// Todo
if (errors.length > 0) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Invalid number',
});
}
return {
customText: fieldValue.value.toString(),
inserted: true,
};
})
.with({ type: FieldType.TEXT }, (fieldValue) => {
if (fieldValue.value === null) {
return {
customText: '',
inserted: false,
};
}
const parsedTextFieldMeta = ZTextFieldMeta.parse(field.fieldMeta);
const errors = validateTextField(fieldValue.value, parsedTextFieldMeta, true);
// Todo
if (errors.length > 0) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Invalid email',
});
}
return {
customText: fieldValue.value,
inserted: true,
};
})
.with({ type: FieldType.RADIO }, (fieldValue) => {
if (fieldValue.value === null) {
return {
customText: '',
inserted: false,
};
}
const parsedRadioFieldParsedMeta = ZRadioFieldMeta.parse(field.fieldMeta);
const errors = validateRadioField(fieldValue.value, parsedRadioFieldParsedMeta, true);
if (errors.length > 0) {
throw new Error(errors.join(', '));
}
// Todo
if (errors.length > 0) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Invalid radio value',
});
}
return {
customText: fieldValue.value,
inserted: true,
};
})
.with({ type: FieldType.CHECKBOX }, (fieldValue) => {
if (fieldValue.value === null) {
return {
customText: '',
inserted: false,
};
}
// Todo: Envelopes - This won't work.
const parsedCheckboxFieldParsedMeta = ZCheckboxFieldMeta.parse(field.fieldMeta);
const checkboxFieldValues = parsedCheckboxFieldParsedMeta.values || [];
const { value } = fieldValue;
const selectedValues = checkboxFieldValues.filter(({ id }) => value.some((v) => v === id));
if (selectedValues.length !== value.length) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Selected values do not match the checkbox field values',
});
}
const errors = validateCheckboxField(
selectedValues.map(({ value }) => value),
parsedCheckboxFieldParsedMeta,
true,
);
if (errors.length > 0) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Invalid checkbox value:' + errors.join(', '),
});
}
return {
customText: JSON.stringify(fieldValue.value),
inserted: true,
};
})
.with({ type: FieldType.DROPDOWN }, (fieldValue) => {
if (fieldValue.value === null) {
return {
customText: '',
inserted: false,
};
}
const parsedDropdownFieldMeta = ZDropdownFieldMeta.parse(field.fieldMeta);
const errors = validateDropdownField(fieldValue.value, parsedDropdownFieldMeta, true);
// Todo
if (errors.length > 0) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Invalid dropdown value',
});
}
return {
customText: fieldValue.value,
inserted: true,
};
})
.with({ type: FieldType.SIGNATURE }, (fieldValue) => {
const { value, isBase64 } = fieldValue;
if (!value) {
return {
customText: '',
inserted: false,
};
}
signatureImageAsBase64 = isBase64 ? value : null;
typedSignature = !isBase64 ? value : null;
if (documentMeta.typedSignatureEnabled === false && typedSignature) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Typed signatures are not allowed. Please draw your signature',
});
}
return {
customText: '',
inserted: true,
};
})
.exhaustive();
const insertionValues = extractFieldInsertionValues({ fieldValue, field, documentMeta });
const derivedRecipientActionAuth = await validateFieldAuth({
documentAuthOptions: envelope.authOptions,
@ -374,6 +143,24 @@ export const signEnvelopeFieldRoute = procedure
const assistant = recipient.role === RecipientRole.ASSISTANT ? recipient : undefined;
let signatureImageAsBase64 = null;
let typedSignature = null;
if (field.type === FieldType.SIGNATURE) {
if (fieldValue.type !== FieldType.SIGNATURE) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `Field ${fieldId} is not a signature field`,
});
}
if (fieldValue.value) {
const isBase64 = isBase64Image(fieldValue.value);
signatureImageAsBase64 = isBase64 ? fieldValue.value : null;
typedSignature = !isBase64 ? fieldValue.value : null;
}
}
return await prisma.$transaction(async (tx) => {
const updatedField = await tx.field.update({
where: {

View File

@ -8,11 +8,11 @@ import SignatureSchema from '@documenso/prisma/generated/zod/modelSchema/Signatu
export const ZSignEnvelopeFieldValue = z.discriminatedUnion('type', [
z.object({
type: z.literal(FieldType.CHECKBOX),
value: z.array(z.number()),
value: z.array(z.number()).describe('The indices of the selected options'),
}),
z.object({
type: z.literal(FieldType.RADIO),
value: z.string().nullable(),
value: z.number().nullable().describe('The index of the selected option'),
}),
z.object({
type: z.literal(FieldType.NUMBER),
@ -45,7 +45,6 @@ export const ZSignEnvelopeFieldValue = z.discriminatedUnion('type', [
z.object({
type: z.literal(FieldType.SIGNATURE),
value: z.string().nullable(),
isBase64: z.boolean(),
}),
]);

View File

@ -90,8 +90,7 @@ export const updateEnvelopeItemsRoute = authenticatedProcedure
),
);
// Todo: Envelope - Audit logs?
// Todo: Envelopes - Delete the document data as well?
// Todo: Envelope [AUDIT_LOGS]
return {
updatedEnvelopeItems,

View File

@ -8,7 +8,7 @@ export const ZUpdateEnvelopeItemsRequestSchema = z.object({
envelopeId: z.string(),
data: z
.object({
envelopeItemId: z.string(),
envelopeItemId: z.string().describe('The ID of the envelope item to update.'),
order: z.number().int().min(1).optional(),
title: ZDocumentTitleSchema.optional(),
})