This commit is contained in:
David Nguyen
2026-02-19 11:36:36 +11:00
parent 3acc029fef
commit e2b3597c36
11 changed files with 2162 additions and 305 deletions
+102 -51
View File
@@ -10,6 +10,12 @@ import { TeamSchema } from '@documenso/prisma/generated/zod/modelSchema/TeamSche
import { TemplateDirectLinkSchema } from '@documenso/prisma/generated/zod/modelSchema/TemplateDirectLinkSchema';
import { ZBaseEmbedDataSchema } from '@documenso/remix/app/types/embed-base-schemas';
/**
* DO NOT MAKE ANY BREAKING BACKWARD CHANGES HERE UNLESS YOu'RE SURE
* IT WON'T BREAK EMBEDDINGS.
*
* Keep this in sync with the embedded repo (the types + schema)
*/
export const ZEnvelopeEditorSettingsSchema = z.object({
/**
* Generic editor related configurations.
@@ -92,15 +98,109 @@ export const ZEnvelopeEditorSettingsSchema = z.object({
export type TEnvelopeEditorSettings = z.infer<typeof ZEnvelopeEditorSettingsSchema>;
/**
* The default editor configuration for normal flows.
*/
export const DEFAULT_EDITOR_CONFIG: EnvelopeEditorConfig = {
general: {
allowConfigureEnvelopeTitle: true,
allowUploadAndRecipientStep: true,
allowAddFieldsStep: true,
allowPreviewStep: true,
minimizeLeftSidebar: false,
},
settings: {
allowConfigureSignatureTypes: true,
allowConfigureLanguage: true,
allowConfigureDateFormat: true,
allowConfigureTimezone: true,
allowConfigureRedirectUrl: true,
allowConfigureDistribution: true,
},
actions: {
allowAttachments: true,
allowDistributing: true,
allowDirectLink: true,
allowDuplication: true,
allowDownloadPDF: true,
allowDeletion: true,
allowReturnToPreviousPage: true,
},
envelopeItems: {
allowConfigureTitle: true,
allowConfigureOrder: true,
allowUpload: true,
allowDelete: true,
},
recipients: {
allowAIDetection: true,
allowConfigureSigningOrder: true,
allowConfigureDictateNextSigner: true,
allowApproverRole: true,
allowViewerRole: true,
allowCCerRole: true,
allowAssistantRole: true,
},
fields: {
allowAIDetection: true,
},
};
export const DEFAULT_EMBEDDED_EDITOR_CONFIG = {
general: {
allowConfigureEnvelopeTitle: true,
allowUploadAndRecipientStep: true,
allowAddFieldsStep: true,
allowPreviewStep: true,
minimizeLeftSidebar: true,
},
settings: {
allowConfigureSignatureTypes: true,
allowConfigureLanguage: true,
allowConfigureDateFormat: true,
allowConfigureTimezone: true,
allowConfigureRedirectUrl: true,
allowConfigureDistribution: true,
},
actions: {
allowAttachments: true,
allowDistributing: false,
allowDirectLink: false,
allowDuplication: false,
allowDownloadPDF: false,
allowDeletion: false,
allowReturnToPreviousPage: true,
},
envelopeItems: {
allowConfigureTitle: true,
allowConfigureOrder: true,
allowUpload: true,
allowDelete: true,
},
recipients: {
allowAIDetection: true,
allowConfigureSigningOrder: true,
allowConfigureDictateNextSigner: true,
allowApproverRole: true,
allowViewerRole: true,
allowCCerRole: true,
allowAssistantRole: true,
},
fields: {
allowAIDetection: true,
},
} as const satisfies EnvelopeEditorConfig;
export const ZEmbedCreateEnvelopeAuthoringSchema = ZBaseEmbedDataSchema.extend({
externalId: z.string().optional(),
type: z.nativeEnum(EnvelopeType),
features: ZEnvelopeEditorSettingsSchema,
features: z.object({}).passthrough().optional().default(DEFAULT_EMBEDDED_EDITOR_CONFIG),
});
export const ZEmbedEditEnvelopeAuthoringSchema = ZBaseEmbedDataSchema.extend({
externalId: z.string().optional(),
features: ZEnvelopeEditorSettingsSchema,
features: z.object({}).passthrough().optional().default(DEFAULT_EMBEDDED_EDITOR_CONFIG),
});
export type TEmbedCreateEnvelopeAuthoring = z.infer<typeof ZEmbedCreateEnvelopeAuthoringSchema>;
@@ -189,52 +289,3 @@ export type EnvelopeEditorConfig = TEnvelopeEditorSettings & {
customBrandingLogo?: boolean;
};
};
/**
* The default editor configuration for normal flows.
*/
export const DEFAULT_EDITOR_CONFIG: EnvelopeEditorConfig = {
general: {
allowConfigureEnvelopeTitle: true,
allowUploadAndRecipientStep: true,
allowAddFieldsStep: true,
allowPreviewStep: true,
minimizeLeftSidebar: false,
},
settings: {
allowConfigureSignatureTypes: true,
allowConfigureLanguage: true,
allowConfigureDateFormat: true,
allowConfigureTimezone: true,
allowConfigureRedirectUrl: true,
allowConfigureDistribution: true,
},
actions: {
allowAttachments: true,
allowDistributing: true,
allowDirectLink: true,
allowDuplication: true,
allowDownloadPDF: true,
allowDeletion: true,
allowReturnToPreviousPage: true,
},
envelopeItems: {
allowConfigureTitle: true,
allowConfigureOrder: true,
allowUpload: true,
allowDelete: true,
},
recipients: {
allowAIDetection: true,
allowConfigureSigningOrder: true,
allowConfigureDictateNextSigner: true,
allowApproverRole: true,
allowViewerRole: true,
allowCCerRole: true,
allowAssistantRole: true,
},
fields: {
allowAIDetection: true,
},
};
+103 -123
View File
@@ -1,60 +1,9 @@
import type { EnvelopeEditorConfig, TEnvelopeEditorSettings } from '../types/envelope-editor';
import type { EnvelopeEditorConfig } from '../types/envelope-editor';
import { DEFAULT_EMBEDDED_EDITOR_CONFIG } from '../types/envelope-editor';
export const PRESIGNED_ENVELOPE_ITEM_ID_PREFIX = 'PRESIGNED_';
/**
* Embedded-mode defaults for the editor config.
*
* - `general`: All true, `minimizeLeftSidebar: true`
* - `settings`: All true
* - `actions`: `allowAttachments: true`; everything else false (parent app controls lifecycle)
* - `envelopeItems`: All true
* - `recipients`: All true
*/
const EMBEDDED_DEFAULTS = {
general: {
allowConfigureEnvelopeTitle: true,
allowUploadAndRecipientStep: true,
allowAddFieldsStep: true,
allowPreviewStep: true,
minimizeLeftSidebar: true,
},
settings: {
allowConfigureSignatureTypes: true,
allowConfigureLanguage: true,
allowConfigureDateFormat: true,
allowConfigureTimezone: true,
allowConfigureRedirectUrl: true,
allowConfigureDistribution: true,
},
actions: {
allowAttachments: true,
allowDistributing: false,
allowDirectLink: false,
allowDuplication: false,
allowDownloadPDF: false,
allowDeletion: false,
allowReturnToPreviousPage: false,
},
envelopeItems: {
allowConfigureTitle: true,
allowConfigureOrder: true,
allowUpload: true,
allowDelete: true,
},
recipients: {
allowAIDetection: true,
allowConfigureSigningOrder: true,
allowConfigureDictateNextSigner: true,
allowApproverRole: true,
allowViewerRole: true,
allowCCerRole: true,
allowAssistantRole: true,
},
fields: {
allowAIDetection: true,
},
} as const satisfies TEnvelopeEditorSettings;
export type DeepPartial<T> = T extends object ? { [K in keyof T]?: DeepPartial<T[K]> } : T;
/**
* Takes parsed `features` from the embedding hash and an `embeded` config,
@@ -62,101 +11,132 @@ const EMBEDDED_DEFAULTS = {
*
* Any explicitly provided feature flag overrides the embedded default.
*/
export function buildEditorConfigFromFeatures(
features: TEnvelopeEditorSettings,
export function buildEmbeddedEditorOptions(
features: DeepPartial<EnvelopeEditorConfig>,
embeded: EnvelopeEditorConfig['embeded'],
): EnvelopeEditorConfig {
return {
embeded,
...buildEmbeddedFeatures(features),
};
}
export const buildEmbeddedFeatures = (
features: DeepPartial<EnvelopeEditorConfig>,
): EnvelopeEditorConfig => {
return {
general: {
allowConfigureEnvelopeTitle:
features.general.allowConfigureEnvelopeTitle ??
EMBEDDED_DEFAULTS.general.allowConfigureEnvelopeTitle,
features.general?.allowConfigureEnvelopeTitle ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.general.allowConfigureEnvelopeTitle,
allowUploadAndRecipientStep:
features.general.allowUploadAndRecipientStep ??
EMBEDDED_DEFAULTS.general.allowUploadAndRecipientStep,
features.general?.allowUploadAndRecipientStep ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.general.allowUploadAndRecipientStep,
allowAddFieldsStep:
features.general.allowAddFieldsStep ?? EMBEDDED_DEFAULTS.general.allowAddFieldsStep,
features.general?.allowAddFieldsStep ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.general.allowAddFieldsStep,
allowPreviewStep:
features.general.allowPreviewStep ?? EMBEDDED_DEFAULTS.general.allowPreviewStep,
features.general?.allowPreviewStep ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.general.allowPreviewStep,
minimizeLeftSidebar:
features.general.minimizeLeftSidebar ?? EMBEDDED_DEFAULTS.general.minimizeLeftSidebar,
features.general?.minimizeLeftSidebar ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.general.minimizeLeftSidebar,
},
settings: {
allowConfigureSignatureTypes:
features.settings?.allowConfigureSignatureTypes ??
EMBEDDED_DEFAULTS.settings.allowConfigureSignatureTypes,
allowConfigureLanguage:
features.settings?.allowConfigureLanguage ??
EMBEDDED_DEFAULTS.settings.allowConfigureLanguage,
allowConfigureDateFormat:
features.settings?.allowConfigureDateFormat ??
EMBEDDED_DEFAULTS.settings.allowConfigureDateFormat,
allowConfigureTimezone:
features.settings?.allowConfigureTimezone ??
EMBEDDED_DEFAULTS.settings.allowConfigureTimezone,
allowConfigureRedirectUrl:
features.settings?.allowConfigureRedirectUrl ??
EMBEDDED_DEFAULTS.settings.allowConfigureRedirectUrl,
allowConfigureDistribution:
features.settings?.allowConfigureDistribution ??
EMBEDDED_DEFAULTS.settings.allowConfigureDistribution,
},
settings:
features.settings !== null
? {
allowConfigureSignatureTypes:
features.settings?.allowConfigureSignatureTypes ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureSignatureTypes,
allowConfigureLanguage:
features.settings?.allowConfigureLanguage ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureLanguage,
allowConfigureDateFormat:
features.settings?.allowConfigureDateFormat ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureDateFormat,
allowConfigureTimezone:
features.settings?.allowConfigureTimezone ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureTimezone,
allowConfigureRedirectUrl:
features.settings?.allowConfigureRedirectUrl ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureRedirectUrl,
allowConfigureDistribution:
features.settings?.allowConfigureDistribution ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureDistribution,
}
: null,
actions: {
allowAttachments:
features.actions.allowAttachments ?? EMBEDDED_DEFAULTS.actions.allowAttachments,
features.actions?.allowAttachments ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.actions.allowAttachments,
allowDistributing:
features.actions.allowDistributing ?? EMBEDDED_DEFAULTS.actions.allowDistributing,
features.actions?.allowDistributing ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.actions.allowDistributing,
allowDirectLink:
features.actions.allowDirectLink ?? EMBEDDED_DEFAULTS.actions.allowDirectLink,
features.actions?.allowDirectLink ?? DEFAULT_EMBEDDED_EDITOR_CONFIG.actions.allowDirectLink,
allowDuplication:
features.actions.allowDuplication ?? EMBEDDED_DEFAULTS.actions.allowDuplication,
features.actions?.allowDuplication ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.actions.allowDuplication,
allowDownloadPDF:
features.actions.allowDownloadPDF ?? EMBEDDED_DEFAULTS.actions.allowDownloadPDF,
allowDeletion: features.actions.allowDeletion ?? EMBEDDED_DEFAULTS.actions.allowDeletion,
features.actions?.allowDownloadPDF ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.actions.allowDownloadPDF,
allowDeletion:
features.actions?.allowDeletion ?? DEFAULT_EMBEDDED_EDITOR_CONFIG.actions.allowDeletion,
allowReturnToPreviousPage:
features.actions.allowReturnToPreviousPage ??
EMBEDDED_DEFAULTS.actions.allowReturnToPreviousPage,
features.actions?.allowReturnToPreviousPage ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.actions.allowReturnToPreviousPage,
},
envelopeItems: {
allowConfigureTitle:
features.envelopeItems?.allowConfigureTitle ??
EMBEDDED_DEFAULTS.envelopeItems.allowConfigureTitle,
allowConfigureOrder:
features.envelopeItems?.allowConfigureOrder ??
EMBEDDED_DEFAULTS.envelopeItems.allowConfigureOrder,
allowUpload:
features.envelopeItems?.allowUpload ?? EMBEDDED_DEFAULTS.envelopeItems.allowUpload,
allowDelete:
features.envelopeItems?.allowDelete ?? EMBEDDED_DEFAULTS.envelopeItems.allowDelete,
},
envelopeItems:
features.envelopeItems !== null
? {
allowConfigureTitle:
features.envelopeItems?.allowConfigureTitle ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.envelopeItems.allowConfigureTitle,
allowConfigureOrder:
features.envelopeItems?.allowConfigureOrder ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.envelopeItems.allowConfigureOrder,
allowUpload:
features.envelopeItems?.allowUpload ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.envelopeItems.allowUpload,
allowDelete:
features.envelopeItems?.allowDelete ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.envelopeItems.allowDelete,
}
: null,
recipients: {
allowAIDetection:
features.recipients?.allowAIDetection ?? EMBEDDED_DEFAULTS.recipients.allowAIDetection,
allowConfigureSigningOrder:
features.recipients?.allowConfigureSigningOrder ??
EMBEDDED_DEFAULTS.recipients.allowConfigureSigningOrder,
allowConfigureDictateNextSigner:
features.recipients?.allowConfigureDictateNextSigner ??
EMBEDDED_DEFAULTS.recipients.allowConfigureDictateNextSigner,
allowApproverRole:
features.recipients?.allowApproverRole ?? EMBEDDED_DEFAULTS.recipients.allowApproverRole,
allowViewerRole:
features.recipients?.allowViewerRole ?? EMBEDDED_DEFAULTS.recipients.allowViewerRole,
allowCCerRole:
features.recipients?.allowCCerRole ?? EMBEDDED_DEFAULTS.recipients.allowCCerRole,
allowAssistantRole:
features.recipients?.allowAssistantRole ?? EMBEDDED_DEFAULTS.recipients.allowAssistantRole,
},
recipients:
features.recipients !== null
? {
allowAIDetection:
features.recipients?.allowAIDetection ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.recipients.allowAIDetection,
allowConfigureSigningOrder:
features.recipients?.allowConfigureSigningOrder ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.recipients.allowConfigureSigningOrder,
allowConfigureDictateNextSigner:
features.recipients?.allowConfigureDictateNextSigner ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.recipients.allowConfigureDictateNextSigner,
allowApproverRole:
features.recipients?.allowApproverRole ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.recipients.allowApproverRole,
allowViewerRole:
features.recipients?.allowViewerRole ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.recipients.allowViewerRole,
allowCCerRole:
features.recipients?.allowCCerRole ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.recipients.allowCCerRole,
allowAssistantRole:
features.recipients?.allowAssistantRole ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.recipients.allowAssistantRole,
}
: null,
fields: {
allowAIDetection:
features.fields?.allowAIDetection ?? EMBEDDED_DEFAULTS.fields.allowAIDetection,
features.fields?.allowAIDetection ?? DEFAULT_EMBEDDED_EDITOR_CONFIG.fields.allowAIDetection,
},
};
}
};
@@ -0,0 +1,389 @@
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 { 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 { canEnvelopeItemsBeModified } from '@documenso/lib/utils/envelope';
import { prisma } from '@documenso/prisma';
import { UNSAFE_createEnvelopeItems } from '@documenso/trpc/server/envelope-router/create-envelope-items';
import { UNSAFE_deleteEnvelopeItem } from '@documenso/trpc/server/envelope-router/delete-envelope-item';
import { UNSAFE_updateEnvelopeItems } from '@documenso/trpc/server/envelope-router/update-envelope-items';
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,
},
});
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[] = [];
// Sort and group envelope items to update and create.
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',
});
}
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;
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 && !canEnvelopeItemsBeModified(envelope, envelope.recipients)) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Envelope item is not editable',
});
}
if (envelopeItemIdsToDelete.length > 0) {
console.log('[DEBUG]: Deleting envelope items', envelopeItemIdsToDelete);
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) {
console.log('[DEBUG]: Creating envelope items', envelopeItemsToCreate);
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
// embeded 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) {
console.log('[DEBUG]: Updating envelope items', envelopeItemsToUpdate);
await UNSAFE_updateEnvelopeItems({
envelopeId: envelope.id,
data: envelopeItemsToUpdate,
});
}
// 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,
})),
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,
})),
}),
)
.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();
});
type EnvelopeItemUpdateOptions = {
envelopeItemId: string;
title?: string;
order?: number;
};
type EnvelopeItemCreateOptions = {
embeddedEnvelopeItemId: string;
title: string;
order: number;
file: File;
};
@@ -0,0 +1,94 @@
import { z } from 'zod';
import { zfd } from 'zod-form-data';
import {
ZDocumentAccessAuthTypesSchema,
ZDocumentActionAuthTypesSchema,
} from '@documenso/lib/types/document-auth';
import { ZDocumentMetaUpdateSchema } from '@documenso/lib/types/document-meta';
import {
ZClampedFieldHeightSchema,
ZClampedFieldPositionXSchema,
ZClampedFieldPositionYSchema,
ZClampedFieldWidthSchema,
ZFieldPageNumberSchema,
} from '@documenso/lib/types/field';
import { ZEnvelopeFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
import { ZSetEnvelopeRecipientSchema } from '@documenso/trpc/server/envelope-router/set-envelope-recipients.types';
import { zodFormData } from '../../utils/zod-form-data';
import {
ZDocumentExternalIdSchema,
ZDocumentTitleSchema,
ZDocumentVisibilitySchema,
} from '../document-router/schema';
export const ZUpdateEmbeddingEnvelopePayloadSchema = z.object({
envelopeId: z.string(),
data: z.object({
title: ZDocumentTitleSchema.optional(),
externalId: ZDocumentExternalIdSchema.nullish(),
visibility: ZDocumentVisibilitySchema.optional(),
globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema).optional(),
globalActionAuth: z.array(ZDocumentActionAuthTypesSchema).optional(),
folderId: z.string().nullish(),
/**
* The list of envelope items that are part of the envelope.
*
* Any missing IDs will be treated as deleting the envelope item.
*/
envelopeItems: z
.object({
/**
* This is not necesssarily a real id, it can be a temporary id for the envelope item.
*/
id: z.string(),
/**
* The title of the envelope item.
*/
title: z.string(),
/**
* The order of the envelope item in the envelope.
*/
order: z.number().int().min(0),
/**
* The file index for items that are not yet uploaded.
*/
index: z.number().int().min(0).optional(),
})
.array(),
/**
* This is a set command.
*/
recipients: ZSetEnvelopeRecipientSchema.extend({
fields: ZEnvelopeFieldAndMetaSchema.and(
z.object({
id: z.number().optional(),
page: ZFieldPageNumberSchema,
positionX: ZClampedFieldPositionXSchema,
positionY: ZClampedFieldPositionYSchema,
width: ZClampedFieldWidthSchema,
height: ZClampedFieldHeightSchema,
envelopeItemId: z.string(),
}),
).array(),
}).array(),
}),
meta: ZDocumentMetaUpdateSchema.optional(),
});
export const ZUpdateEmbeddingEnvelopeRequestSchema = zodFormData({
payload: zfd.json(ZUpdateEmbeddingEnvelopePayloadSchema),
files: zfd.repeatableOfType(zfd.file()),
});
export const ZUpdateEmbeddingEnvelopeResponseSchema = z.void();
export type TUpdateEmbeddingEnvelopePayload = z.infer<typeof ZUpdateEmbeddingEnvelopePayloadSchema>;
export type TUpdateEmbeddingEnvelopeRequest = z.infer<typeof ZUpdateEmbeddingEnvelopeRequestSchema>;
@@ -1,3 +1,5 @@
import type { Envelope, EnvelopeItem, Recipient } 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 {
@@ -8,6 +10,7 @@ import { findRecipientByPlaceholder } from '@documenso/lib/server-only/pdf/helpe
import { insertFormValuesInPdf } from '@documenso/lib/server-only/pdf/insert-form-values-in-pdf';
import { normalizePdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { prefixedId } from '@documenso/lib/universal/id';
import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
@@ -91,133 +94,186 @@ export const createEnvelopeItemsRoute = authenticatedProcedure
});
}
// For each file: normalize, extract & clean placeholders, then upload.
const envelopeItems = await Promise.all(
files.map(async (file) => {
let buffer = Buffer.from(await file.arrayBuffer());
if (envelope.formValues) {
buffer = await insertFormValuesInPdf({ pdf: buffer, formValues: envelope.formValues });
}
const normalized = await normalizePdf(buffer, {
flattenForm: envelope.type !== 'TEMPLATE',
});
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized);
const { id: documentDataId } = await putPdfFileServerSide({
name: file.name,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(cleanedPdf),
});
return {
title: file.name,
documentDataId,
placeholders,
};
}),
);
const currentHighestOrderValue =
envelope.envelopeItems[envelope.envelopeItems.length - 1]?.order ?? 1;
const result = await prisma.$transaction(async (tx) => {
const createdItems = await tx.envelopeItem.createManyAndReturn({
data: envelopeItems.map((item) => ({
id: prefixedId('envelope_item'),
envelopeId,
title: item.title,
documentDataId: item.documentDataId,
order: currentHighestOrderValue + 1,
})),
include: {
documentData: true,
},
});
await tx.documentAuditLog.createMany({
data: createdItems.map((item) =>
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.ENVELOPE_ITEM_CREATED,
envelopeId: envelope.id,
data: {
envelopeItemId: item.id,
envelopeItemTitle: item.title,
},
user: {
name: user.name,
email: user.email,
},
requestMetadata: metadata.requestMetadata,
}),
),
});
// Create fields from placeholders if the envelope already has recipients.
if (envelope.recipients.length > 0) {
const orderedRecipients = [...envelope.recipients].sort((a, b) => {
const aOrder = a.signingOrder ?? Number.MAX_SAFE_INTEGER;
const bOrder = b.signingOrder ?? Number.MAX_SAFE_INTEGER;
if (aOrder !== bOrder) {
return aOrder - bOrder;
}
return a.id - b.id;
});
for (const uploadedItem of envelopeItems) {
if (!uploadedItem.placeholders || uploadedItem.placeholders.length === 0) {
continue;
}
const createdItem = createdItems.find(
(ci) => ci.documentDataId === uploadedItem.documentDataId,
);
if (!createdItem) {
continue;
}
const fieldsToCreate = convertPlaceholdersToFieldInputs(
uploadedItem.placeholders,
(recipientPlaceholder, placeholder) =>
findRecipientByPlaceholder(
recipientPlaceholder,
placeholder,
orderedRecipients,
orderedRecipients,
),
createdItem.id,
);
if (fieldsToCreate.length > 0) {
await tx.field.createMany({
data: fieldsToCreate.map((field) => ({
envelopeId: envelope.id,
envelopeItemId: createdItem.id,
recipientId: field.recipientId,
type: field.type,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
customText: '',
inserted: false,
fieldMeta: field.fieldMeta || undefined,
})),
});
}
}
}
return createdItems;
const result = await UNSAFE_createEnvelopeItems({
files: files.map((file) => ({
file,
})),
envelope,
user: {
id: user.id,
name: user.name,
email: user.email,
},
apiRequestMetadata: metadata,
});
return {
data: result,
};
});
type UnsafeCreateEnvelopeItemsOptions = {
files: {
clientId?: string;
file: File;
orderOverride?: number;
}[];
envelope: Envelope & {
envelopeItems: EnvelopeItem[];
recipients: Recipient[];
};
user: {
id: number;
name: string | null;
email: string;
};
apiRequestMetadata: ApiRequestMetadata;
};
/**
* Create envelope items.
*
* It is assumed all prior validation has been completed.
*/
export const UNSAFE_createEnvelopeItems = async ({
files,
envelope,
user,
apiRequestMetadata,
}: UnsafeCreateEnvelopeItemsOptions) => {
const currentHighestOrderValue =
envelope.envelopeItems[envelope.envelopeItems.length - 1]?.order ?? 1;
// For each file: normalize, extract & clean placeholders, then upload.
const envelopeItemsToCreate = await Promise.all(
files.map(async ({ file, orderOverride, clientId }, index) => {
let buffer = Buffer.from(await file.arrayBuffer());
if (envelope.formValues) {
buffer = await insertFormValuesInPdf({ pdf: buffer, formValues: envelope.formValues });
}
const normalized = await normalizePdf(buffer, {
flattenForm: envelope.type !== 'TEMPLATE',
});
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized);
const { id: documentDataId } = await putPdfFileServerSide({
name: file.name,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(cleanedPdf),
});
return {
id: prefixedId('envelope_item'),
title: file.name,
clientId,
documentDataId,
placeholders,
order: orderOverride ?? currentHighestOrderValue + index + 1,
};
}),
);
return await prisma.$transaction(async (tx) => {
const createdItems = await tx.envelopeItem.createManyAndReturn({
data: envelopeItemsToCreate.map((item) => ({
id: item.id,
envelopeId: envelope.id,
title: item.title,
documentDataId: item.documentDataId,
order: item.order,
})),
include: {
documentData: true,
},
});
await tx.documentAuditLog.createMany({
data: createdItems.map((item) =>
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.ENVELOPE_ITEM_CREATED,
envelopeId: envelope.id,
data: {
envelopeItemId: item.id,
envelopeItemTitle: item.title,
},
user: {
name: user.name,
email: user.email,
},
requestMetadata: apiRequestMetadata.requestMetadata,
}),
),
});
// Create fields from placeholders if the envelope already has recipients.
if (envelope.recipients.length > 0) {
const orderedRecipients = [...envelope.recipients].sort((a, b) => {
const aOrder = a.signingOrder ?? Number.MAX_SAFE_INTEGER;
const bOrder = b.signingOrder ?? Number.MAX_SAFE_INTEGER;
if (aOrder !== bOrder) {
return aOrder - bOrder;
}
return a.id - b.id;
});
for (const uploadedItem of envelopeItemsToCreate) {
if (!uploadedItem.placeholders || uploadedItem.placeholders.length === 0) {
continue;
}
const createdItem = createdItems.find(
(ci) => ci.documentDataId === uploadedItem.documentDataId,
);
if (!createdItem) {
continue;
}
const fieldsToCreate = convertPlaceholdersToFieldInputs(
uploadedItem.placeholders,
(recipientPlaceholder, placeholder) =>
findRecipientByPlaceholder(
recipientPlaceholder,
placeholder,
orderedRecipients,
orderedRecipients,
),
createdItem.id,
);
if (fieldsToCreate.length > 0) {
await tx.field.createMany({
data: fieldsToCreate.map((field) => ({
envelopeId: envelope.id,
envelopeItemId: createdItem.id,
recipientId: field.recipientId,
type: field.type,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
customText: '',
inserted: false,
fieldMeta: field.fieldMeta || undefined,
})),
});
}
}
}
return createdItems.map((item) => {
const clientId = envelopeItemsToCreate.find((file) => file.id === item.id)?.clientId;
return {
...item,
clientId,
};
});
});
};