mirror of
https://github.com/documenso/documenso
synced 2026-04-21 13:27:18 +00:00
517 lines
16 KiB
TypeScript
517 lines
16 KiB
TypeScript
import { DocumentStatus, EnvelopeType } from '@prisma/client';
|
|
import pMap from 'p-map';
|
|
import { match } from 'ts-pattern';
|
|
|
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
|
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
|
|
import { UNSAFE_createEnvelopeItems } from '@documenso/lib/server-only/envelope-item/create-envelope-items';
|
|
import { UNSAFE_deleteEnvelopeItem } from '@documenso/lib/server-only/envelope-item/delete-envelope-item';
|
|
import { UNSAFE_replaceEnvelopeItemPdf } from '@documenso/lib/server-only/envelope-item/replace-envelope-item-pdf';
|
|
import { UNSAFE_updateEnvelopeItems } from '@documenso/lib/server-only/envelope-item/update-envelope-items';
|
|
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
|
import { updateEnvelope } from '@documenso/lib/server-only/envelope/update-envelope';
|
|
import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document';
|
|
import { setFieldsForTemplate } from '@documenso/lib/server-only/field/set-fields-for-template';
|
|
import { setDocumentRecipients } from '@documenso/lib/server-only/recipient/set-document-recipients';
|
|
import { setTemplateRecipients } from '@documenso/lib/server-only/recipient/set-template-recipients';
|
|
import { nanoid } from '@documenso/lib/universal/id';
|
|
import { PRESIGNED_ENVELOPE_ITEM_ID_PREFIX } from '@documenso/lib/utils/embed-config';
|
|
import { getEnvelopeItemPermissions } from '@documenso/lib/utils/envelope';
|
|
import { prisma } from '@documenso/prisma';
|
|
|
|
import { procedure } from '../trpc';
|
|
import {
|
|
ZUpdateEmbeddingEnvelopeRequestSchema,
|
|
ZUpdateEmbeddingEnvelopeResponseSchema,
|
|
} from './update-embedding-envelope.types';
|
|
|
|
export const updateEmbeddingEnvelopeRoute = procedure
|
|
.input(ZUpdateEmbeddingEnvelopeRequestSchema)
|
|
.output(ZUpdateEmbeddingEnvelopeResponseSchema)
|
|
.mutation(async ({ input, ctx }) => {
|
|
const { payload, files } = input;
|
|
const { envelopeId, data, meta } = payload;
|
|
|
|
ctx.logger.info({
|
|
input: {
|
|
envelopeId,
|
|
},
|
|
});
|
|
|
|
const authorizationHeader = ctx.req.headers.get('authorization');
|
|
|
|
const [presignToken] = (authorizationHeader || '').split('Bearer ').filter((s) => s.length > 0);
|
|
|
|
if (!presignToken) {
|
|
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
|
message: 'No presign token provided',
|
|
});
|
|
}
|
|
|
|
const apiToken = await verifyEmbeddingPresignToken({
|
|
token: presignToken,
|
|
scope: `envelopeId:${envelopeId}`,
|
|
});
|
|
|
|
const { envelopeWhereInput } = await getEnvelopeWhereInput({
|
|
id: {
|
|
type: 'envelopeId',
|
|
id: envelopeId,
|
|
},
|
|
type: null, // Allow updating both documents and templates.
|
|
userId: apiToken.userId,
|
|
teamId: apiToken.teamId,
|
|
});
|
|
|
|
const envelope = await prisma.envelope.findFirst({
|
|
where: envelopeWhereInput,
|
|
include: {
|
|
envelopeItems: true,
|
|
team: {
|
|
select: {
|
|
organisation: {
|
|
select: {
|
|
organisationClaim: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
recipients: true,
|
|
envelopeAttachments: true,
|
|
},
|
|
});
|
|
|
|
if (!envelope) {
|
|
throw new AppError(AppErrorCode.NOT_FOUND, {
|
|
message: 'Envelope not found',
|
|
});
|
|
}
|
|
|
|
if (envelope.status === DocumentStatus.COMPLETED) {
|
|
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
|
message: 'Cannot modify completed envelope',
|
|
});
|
|
}
|
|
|
|
// Step 1: Update the envelope items.
|
|
const envelopeItemsToUpdate: EnvelopeItemUpdateOptions[] = [];
|
|
const envelopeItemsToCreate: EnvelopeItemCreateOptions[] = [];
|
|
const envelopeItemsToReplace: EnvelopeItemReplaceOptions[] = [];
|
|
|
|
// Sort and group envelope items to update, create, and replace.
|
|
data.envelopeItems.forEach((item) => {
|
|
const isNewEnvelopeItem = item.id.startsWith(PRESIGNED_ENVELOPE_ITEM_ID_PREFIX);
|
|
|
|
// Handle existing envelope items.
|
|
if (!isNewEnvelopeItem) {
|
|
const envelopeItem = envelope.envelopeItems.find(
|
|
(envelopeItem) => envelopeItem.id === item.id,
|
|
);
|
|
|
|
if (!envelopeItem) {
|
|
throw new AppError(AppErrorCode.NOT_FOUND, {
|
|
message: 'Envelope item not found',
|
|
});
|
|
}
|
|
|
|
// Check if this existing item has a replacement file.
|
|
if (item.replaceFileIndex !== undefined) {
|
|
const replaceFile = files[item.replaceFileIndex];
|
|
|
|
if (!replaceFile) {
|
|
throw new AppError(AppErrorCode.INVALID_BODY, {
|
|
message: 'Invalid replace file index',
|
|
});
|
|
}
|
|
|
|
envelopeItemsToReplace.push({
|
|
envelopeItemId: envelopeItem.id,
|
|
oldDocumentDataId: envelopeItem.documentDataId,
|
|
title: item.title,
|
|
order: item.order,
|
|
file: replaceFile,
|
|
});
|
|
|
|
return;
|
|
}
|
|
|
|
const hasEnvelopeItemChanged =
|
|
envelopeItem.title !== item.title || envelopeItem.order !== item.order;
|
|
|
|
if (hasEnvelopeItemChanged) {
|
|
envelopeItemsToUpdate.push({
|
|
envelopeItemId: envelopeItem.id,
|
|
title: item.title,
|
|
order: item.order,
|
|
});
|
|
}
|
|
|
|
// Return to continue loop.
|
|
return;
|
|
}
|
|
|
|
const newEnvelopeItemFile = item.index !== undefined ? files[item.index] : undefined;
|
|
|
|
if (!newEnvelopeItemFile) {
|
|
throw new AppError(AppErrorCode.INVALID_BODY, {
|
|
message: 'Invalid envelope item index',
|
|
});
|
|
}
|
|
|
|
// Handle not yet uploaded envelope items.
|
|
envelopeItemsToCreate.push({
|
|
embeddedEnvelopeItemId: item.id,
|
|
title: item.title,
|
|
order: item.order,
|
|
file: newEnvelopeItemFile,
|
|
});
|
|
});
|
|
|
|
// Delete envelope items that have been removed from the payload.
|
|
const envelopeItemIdsToDelete = envelope.envelopeItems
|
|
.filter((item) => !data.envelopeItems.some((i) => i.id === item.id))
|
|
.map((item) => item.id);
|
|
|
|
const willEnvelopeItemsBeModified =
|
|
envelopeItemIdsToDelete.length > 0 ||
|
|
envelopeItemsToCreate.length > 0 ||
|
|
envelopeItemsToUpdate.length > 0 ||
|
|
envelopeItemsToReplace.length > 0;
|
|
|
|
const organisationClaim = envelope.team.organisation.organisationClaim;
|
|
const resultingEnvelopeItemCount =
|
|
envelope.envelopeItems.length - envelopeItemIdsToDelete.length + envelopeItemsToCreate.length;
|
|
|
|
if (resultingEnvelopeItemCount > organisationClaim.envelopeItemCount) {
|
|
throw new AppError('ENVELOPE_ITEM_LIMIT_EXCEEDED', {
|
|
message: `You cannot upload more than ${organisationClaim.envelopeItemCount} envelope items`,
|
|
statusCode: 400,
|
|
});
|
|
}
|
|
|
|
// Should be safe to use stale envelope.recipients since only signed or sent
|
|
// recipients affect the outcome.
|
|
if (willEnvelopeItemsBeModified) {
|
|
const permissions = getEnvelopeItemPermissions(envelope, envelope.recipients);
|
|
|
|
const hasFileChange = envelopeItemIdsToDelete.length > 0 || envelopeItemsToCreate.length > 0;
|
|
|
|
const hasOrderChange = envelopeItemsToUpdate.some((item) => {
|
|
const existing = envelope.envelopeItems.find((e) => e.id === item.envelopeItemId);
|
|
|
|
return !existing || item.order !== existing.order;
|
|
});
|
|
|
|
const hasTitleChange = envelopeItemsToUpdate.some((item) => item.title !== undefined);
|
|
|
|
if (hasFileChange && !permissions.canFileBeChanged) {
|
|
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
|
message: 'Envelope item files are not editable',
|
|
});
|
|
}
|
|
|
|
if (hasOrderChange && !permissions.canOrderBeChanged) {
|
|
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
|
message: 'Envelope item order is not editable',
|
|
});
|
|
}
|
|
|
|
if (hasTitleChange && !permissions.canTitleBeChanged) {
|
|
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
|
message: 'Envelope item title is not editable',
|
|
});
|
|
}
|
|
}
|
|
|
|
if (envelopeItemIdsToDelete.length > 0) {
|
|
await pMap(
|
|
envelopeItemIdsToDelete,
|
|
async (envelopeItemId) => {
|
|
await UNSAFE_deleteEnvelopeItem({
|
|
envelopeId: envelope.id,
|
|
envelopeItemId,
|
|
user: apiToken.user,
|
|
apiRequestMetadata: ctx.metadata,
|
|
});
|
|
},
|
|
{ concurrency: 2 },
|
|
);
|
|
}
|
|
|
|
// Mapping for the client side embedded prefix envelope item IDs to the real envelope item IDs.
|
|
const embeddedEnvelopeItemIdMapping: Record<string, string> = {};
|
|
|
|
// Create new envelope items.
|
|
if (envelopeItemsToCreate.length > 0) {
|
|
const createdEnvelopeItems = await UNSAFE_createEnvelopeItems({
|
|
files: envelopeItemsToCreate.map((item) => ({
|
|
clientId: item.embeddedEnvelopeItemId,
|
|
file: item.file,
|
|
orderOverride: item.order,
|
|
})),
|
|
envelope: {
|
|
...envelope,
|
|
// Purposefully putting empty recipients here since placeholders should automatically injected on the client side for
|
|
// embedded purposes. Todo: Embeds - (Not implemeneted yet)
|
|
recipients: [],
|
|
},
|
|
user: {
|
|
id: apiToken.user.id,
|
|
name: apiToken.user.name,
|
|
email: apiToken.user.email,
|
|
},
|
|
apiRequestMetadata: ctx.metadata,
|
|
});
|
|
|
|
// Build the map from the envelope item order.
|
|
createdEnvelopeItems.forEach((item) => {
|
|
if (!item.clientId) {
|
|
throw new AppError(AppErrorCode.NOT_FOUND, {
|
|
message: 'Client ID not found',
|
|
});
|
|
}
|
|
|
|
embeddedEnvelopeItemIdMapping[item.clientId] = item.id;
|
|
});
|
|
}
|
|
|
|
if (envelopeItemsToUpdate.length > 0) {
|
|
await UNSAFE_updateEnvelopeItems({
|
|
envelopeId: envelope.id,
|
|
envelopeType: envelope.type,
|
|
existingEnvelopeItems: envelope.envelopeItems,
|
|
data: envelopeItemsToUpdate,
|
|
user: {
|
|
name: apiToken.user.name,
|
|
email: apiToken.user.email,
|
|
},
|
|
apiRequestMetadata: ctx.metadata,
|
|
});
|
|
}
|
|
|
|
// Replace PDFs for existing envelope items without creating placeholder fields
|
|
// field cleanup is handled in later steps.
|
|
if (envelopeItemsToReplace.length > 0) {
|
|
await pMap(
|
|
envelopeItemsToReplace,
|
|
async (item) => {
|
|
await UNSAFE_replaceEnvelopeItemPdf({
|
|
envelope,
|
|
recipients: [],
|
|
envelopeItemId: item.envelopeItemId,
|
|
oldDocumentDataId: item.oldDocumentDataId,
|
|
data: {
|
|
title: item.title,
|
|
order: item.order,
|
|
file: item.file,
|
|
},
|
|
user: apiToken.user,
|
|
apiRequestMetadata: ctx.metadata,
|
|
});
|
|
},
|
|
{ concurrency: 2 },
|
|
);
|
|
}
|
|
|
|
// Step 2: Update the general envelope data and meta.
|
|
await updateEnvelope({
|
|
userId: apiToken.userId,
|
|
teamId: apiToken.teamId,
|
|
id: {
|
|
type: 'envelopeId',
|
|
id: envelope.id,
|
|
},
|
|
data: {
|
|
title: data.title,
|
|
externalId: data.externalId,
|
|
visibility: data.visibility,
|
|
globalAccessAuth: data.globalAccessAuth,
|
|
globalActionAuth: data.globalActionAuth,
|
|
folderId: data.folderId,
|
|
},
|
|
meta,
|
|
requestMetadata: ctx.metadata,
|
|
});
|
|
|
|
// Step 3: Update the recipients
|
|
const recipientsWithClientId = data.recipients.map((recipient) => ({
|
|
...recipient,
|
|
clientId: nanoid(),
|
|
}));
|
|
|
|
const { recipients: updatedRecipients } = await match(envelope.type)
|
|
.with(EnvelopeType.DOCUMENT, async () =>
|
|
setDocumentRecipients({
|
|
userId: apiToken.userId,
|
|
teamId: apiToken.teamId,
|
|
id: {
|
|
type: 'envelopeId',
|
|
id: envelope.id,
|
|
},
|
|
recipients: recipientsWithClientId.map((recipient) => ({
|
|
id: recipient.id,
|
|
clientId: recipient.clientId,
|
|
email: recipient.email,
|
|
name: recipient.name ?? '',
|
|
role: recipient.role,
|
|
signingOrder: recipient.signingOrder,
|
|
actionAuth: recipient.actionAuth,
|
|
})),
|
|
requestMetadata: ctx.metadata,
|
|
}),
|
|
)
|
|
.with(EnvelopeType.TEMPLATE, async () =>
|
|
setTemplateRecipients({
|
|
userId: apiToken.userId,
|
|
teamId: apiToken.teamId,
|
|
id: {
|
|
type: 'envelopeId',
|
|
id: envelope.id,
|
|
},
|
|
recipients: recipientsWithClientId.map((recipient) => ({
|
|
id: recipient.id,
|
|
clientId: recipient.clientId,
|
|
email: recipient.email,
|
|
name: recipient.name ?? '',
|
|
role: recipient.role,
|
|
signingOrder: recipient.signingOrder,
|
|
actionAuth: recipient.actionAuth,
|
|
})),
|
|
}),
|
|
)
|
|
.exhaustive();
|
|
|
|
// Step 4: Update the fields.
|
|
const fields = recipientsWithClientId.flatMap((recipient) => {
|
|
const recipientId = updatedRecipients.find((r) => r.clientId === recipient.clientId)?.id;
|
|
|
|
if (!recipientId) {
|
|
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
|
message: 'Recipient not found',
|
|
});
|
|
}
|
|
|
|
return (recipient.fields ?? []).map((field) => {
|
|
let envelopeItemId = field.envelopeItemId;
|
|
|
|
if (envelopeItemId.startsWith(PRESIGNED_ENVELOPE_ITEM_ID_PREFIX)) {
|
|
envelopeItemId = embeddedEnvelopeItemIdMapping[envelopeItemId];
|
|
}
|
|
|
|
if (!envelopeItemId) {
|
|
throw new AppError(AppErrorCode.NOT_FOUND, {
|
|
message: 'Envelope item not found',
|
|
});
|
|
}
|
|
|
|
return {
|
|
...field,
|
|
recipientId,
|
|
envelopeItemId,
|
|
};
|
|
});
|
|
});
|
|
|
|
await match(envelope.type)
|
|
.with(EnvelopeType.DOCUMENT, async () =>
|
|
setFieldsForDocument({
|
|
userId: apiToken.userId,
|
|
teamId: apiToken.teamId,
|
|
id: {
|
|
type: 'envelopeId',
|
|
id: envelopeId,
|
|
},
|
|
fields: fields.map((field) => ({
|
|
...field,
|
|
pageNumber: field.page,
|
|
pageX: field.positionX,
|
|
pageY: field.positionY,
|
|
pageWidth: field.width,
|
|
pageHeight: field.height,
|
|
})),
|
|
requestMetadata: ctx.metadata,
|
|
}),
|
|
)
|
|
.with(EnvelopeType.TEMPLATE, async () =>
|
|
setFieldsForTemplate({
|
|
userId: apiToken.userId,
|
|
teamId: apiToken.teamId,
|
|
id: {
|
|
type: 'envelopeId',
|
|
id: envelopeId,
|
|
},
|
|
fields: fields.map((field) => ({
|
|
...field,
|
|
pageNumber: field.page,
|
|
pageX: field.positionX,
|
|
pageY: field.positionY,
|
|
pageWidth: field.width,
|
|
pageHeight: field.height,
|
|
})),
|
|
}),
|
|
)
|
|
.exhaustive();
|
|
|
|
// Step 5: Handle attachments (set semantics: delete all existing, create new).
|
|
let hasEnvelopeAttachmentsChanged =
|
|
envelope.envelopeAttachments.length !== data.attachments.length;
|
|
|
|
data.attachments.forEach((attachment) => {
|
|
const foundAttachment = envelope.envelopeAttachments.find((a) => a.id === attachment.id);
|
|
|
|
if (!foundAttachment) {
|
|
hasEnvelopeAttachmentsChanged = true;
|
|
return;
|
|
}
|
|
|
|
const hasAttachmentChanged =
|
|
foundAttachment.label !== attachment.label ||
|
|
foundAttachment.data !== attachment.data ||
|
|
foundAttachment.type !== attachment.type;
|
|
|
|
if (hasAttachmentChanged) {
|
|
hasEnvelopeAttachmentsChanged = true;
|
|
return;
|
|
}
|
|
});
|
|
|
|
if (hasEnvelopeAttachmentsChanged) {
|
|
await prisma.envelopeAttachment.deleteMany({
|
|
where: {
|
|
envelopeId: envelope.id,
|
|
},
|
|
});
|
|
|
|
if (data.attachments.length > 0) {
|
|
await prisma.envelopeAttachment.createMany({
|
|
data: data.attachments.map((attachment) => ({
|
|
envelopeId: envelope.id,
|
|
label: attachment.label,
|
|
data: attachment.data,
|
|
type: attachment.type,
|
|
})),
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
type EnvelopeItemUpdateOptions = {
|
|
envelopeItemId: string;
|
|
title?: string;
|
|
order?: number;
|
|
};
|
|
|
|
type EnvelopeItemCreateOptions = {
|
|
embeddedEnvelopeItemId: string;
|
|
title: string;
|
|
order: number;
|
|
file: File;
|
|
};
|
|
|
|
type EnvelopeItemReplaceOptions = {
|
|
envelopeItemId: string;
|
|
oldDocumentDataId: string;
|
|
title: string;
|
|
order: number;
|
|
file: File;
|
|
};
|