This commit is contained in:
David Nguyen
2026-02-17 13:45:09 +11:00
parent 2a53104644
commit 3acc029fef
10 changed files with 582 additions and 85 deletions
@@ -60,6 +60,15 @@ export const verifyEmbeddingPresignToken = async ({
where: {
id: tokenId,
},
include: {
user: {
select: {
id: true,
name: true,
email: true,
},
},
},
});
if (!apiToken) {
@@ -69,7 +78,7 @@ export const verifyEmbeddingPresignToken = async ({
}
// This should never happen but we need to narrow types
if (!apiToken.userId) {
if (!apiToken.userId || !apiToken.user) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Invalid presign token: API token does not have a user attached',
});
@@ -119,5 +128,10 @@ export const verifyEmbeddingPresignToken = async ({
return {
...apiToken,
userId,
user: {
id: apiToken.user.id,
name: apiToken.user.name,
email: apiToken.user.email,
},
};
};
@@ -97,6 +97,14 @@ export type CreateEnvelopeOptions = {
data: string;
type?: TEnvelopeAttachmentType;
}>;
/**
* Whether to bypass adding default recipients.
*
* Defaults to false.
*/
bypassDefaultRecipients?: boolean;
meta?: Partial<Omit<DocumentMeta, 'id'>>;
requestMetadata: ApiRequestMetadata;
};
@@ -110,6 +118,7 @@ export const createEnvelope = async ({
meta,
requestMetadata,
internalVersion,
bypassDefaultRecipients = false,
}: CreateEnvelopeOptions) => {
const {
type,
@@ -355,9 +364,10 @@ export const createEnvelope = async ({
const firstEnvelopeItem = envelope.envelopeItems[0];
const defaultRecipients = settings.defaultRecipients
? ZDefaultRecipientsSchema.parse(settings.defaultRecipients)
: [];
const defaultRecipients =
settings.defaultRecipients && !bypassDefaultRecipients
? ZDefaultRecipientsSchema.parse(settings.defaultRecipients)
: [];
const mappedDefaultRecipients: CreateEnvelopeRecipientOptions[] = defaultRecipients.map(
(recipient) => ({
@@ -21,14 +21,7 @@ export type SetTemplateRecipientsOptions = {
userId: number;
teamId: number;
id: EnvelopeIdOptions;
recipients: {
id?: number;
email: string;
name: string;
role: RecipientRole;
signingOrder?: number | null;
actionAuth?: TRecipientActionAuthTypes[];
}[];
recipients: RecipientData[];
};
export const setTemplateRecipients = async ({
@@ -183,7 +176,10 @@ export const setTemplateRecipients = async ({
});
}
return upsertedRecipient;
return {
...upsertedRecipient,
clientId: recipient.clientId,
};
}),
);
});
@@ -199,7 +195,7 @@ export const setTemplateRecipients = async ({
}
// Filter out recipients that have been removed or have been updated.
const filteredRecipients: Recipient[] = existingRecipients.filter((recipient) => {
const filteredRecipients: RecipientDataWithClientId[] = existingRecipients.filter((recipient) => {
const isRemoved = removedRecipients.find(
(removedRecipient) => removedRecipient.id === recipient.id,
);
@@ -218,3 +214,17 @@ export const setTemplateRecipients = async ({
})),
};
};
type RecipientData = {
id?: number;
clientId?: string | null;
email: string;
name: string;
role: RecipientRole;
signingOrder?: number | null;
actionAuth?: TRecipientActionAuthTypes[];
};
type RecipientDataWithClientId = Recipient & {
clientId?: string | null;
};
+240
View File
@@ -0,0 +1,240 @@
import { EnvelopeType } from '@prisma/client';
import { z } from 'zod';
import { ZEnvelopeFieldSchema } from '@documenso/lib/types/field';
import { ZEnvelopeRecipientLiteSchema } from '@documenso/lib/types/recipient';
import { DocumentMetaSchema } from '@documenso/prisma/generated/zod/modelSchema/DocumentMetaSchema';
import { EnvelopeItemSchema } from '@documenso/prisma/generated/zod/modelSchema/EnvelopeItemSchema';
import { EnvelopeSchema } from '@documenso/prisma/generated/zod/modelSchema/EnvelopeSchema';
import { TeamSchema } from '@documenso/prisma/generated/zod/modelSchema/TeamSchema';
import { TemplateDirectLinkSchema } from '@documenso/prisma/generated/zod/modelSchema/TemplateDirectLinkSchema';
import { ZBaseEmbedDataSchema } from '@documenso/remix/app/types/embed-base-schemas';
export const ZEnvelopeEditorSettingsSchema = z.object({
/**
* Generic editor related configurations.
*/
general: z.object({
allowConfigureEnvelopeTitle: z.boolean(),
allowUploadAndRecipientStep: z.boolean(),
allowAddFieldsStep: z.boolean(),
allowPreviewStep: z.boolean(),
minimizeLeftSidebar: z.boolean(),
}),
/**
* Envelope meta/settings related configuration
*
* If null, the settings will not be available to be seen/updated.
*/
settings: z
.object({
allowConfigureSignatureTypes: z.boolean(),
allowConfigureLanguage: z.boolean(),
allowConfigureDateFormat: z.boolean(),
allowConfigureTimezone: z.boolean(),
allowConfigureRedirectUrl: z.boolean(),
allowConfigureDistribution: z.boolean(),
})
.nullable(),
/**
* Action related configurations.
*/
actions: z.object({
allowAttachments: z.boolean(),
allowDistributing: z.boolean(),
allowDirectLink: z.boolean(),
allowDuplication: z.boolean(),
allowDownloadPDF: z.boolean(),
allowDeletion: z.boolean(),
allowReturnToPreviousPage: z.boolean(),
}),
/**
* Envelope items related configurations.
*
* If null, no adjustments to envelope items will be allowed.
*/
envelopeItems: z
.object({
allowConfigureTitle: z.boolean(),
allowConfigureOrder: z.boolean(),
allowUpload: z.boolean(),
allowDelete: z.boolean(),
})
.nullable(),
/**
* Recipient related configurations.
*
* If null, recipients will not be configurable at all.
*/
recipients: z
.object({
allowAIDetection: z.boolean(),
allowConfigureSigningOrder: z.boolean(),
allowConfigureDictateNextSigner: z.boolean(),
allowApproverRole: z.boolean(),
allowViewerRole: z.boolean(),
allowCCerRole: z.boolean(),
allowAssistantRole: z.boolean(),
})
.nullable(),
/**
* Fields related configurations.
*/
fields: z.object({
allowAIDetection: z.boolean(),
}),
});
export type TEnvelopeEditorSettings = z.infer<typeof ZEnvelopeEditorSettingsSchema>;
export const ZEmbedCreateEnvelopeAuthoringSchema = ZBaseEmbedDataSchema.extend({
externalId: z.string().optional(),
type: z.nativeEnum(EnvelopeType),
features: ZEnvelopeEditorSettingsSchema,
});
export const ZEmbedEditEnvelopeAuthoringSchema = ZBaseEmbedDataSchema.extend({
externalId: z.string().optional(),
features: ZEnvelopeEditorSettingsSchema,
});
export type TEmbedCreateEnvelopeAuthoring = z.infer<typeof ZEmbedCreateEnvelopeAuthoringSchema>;
export type TEmbedEditEnvelopeAuthoring = z.infer<typeof ZEmbedEditEnvelopeAuthoringSchema>;
/**
* A subset of the full envelope response schema used for the envelope editor.
*
* Internal usage only.
*/
export const ZEditorEnvelopeSchema = EnvelopeSchema.pick({
internalVersion: true,
type: true,
status: true,
source: true,
visibility: true,
templateType: true,
id: true,
secondaryId: true,
externalId: true,
completedAt: true,
deletedAt: true,
title: true,
authOptions: true,
publicTitle: true,
publicDescription: true,
userId: true,
teamId: true,
folderId: true,
}).extend({
documentMeta: DocumentMetaSchema.pick({
signingOrder: true,
distributionMethod: true,
id: true,
subject: true,
message: true,
timezone: true,
dateFormat: true,
redirectUrl: true,
typedSignatureEnabled: true,
uploadSignatureEnabled: true,
drawSignatureEnabled: true,
allowDictateNextSigner: true,
language: true,
emailSettings: true,
emailId: true,
emailReplyTo: true,
}),
recipients: ZEnvelopeRecipientLiteSchema.array(),
fields: ZEnvelopeFieldSchema.array(),
envelopeItems: EnvelopeItemSchema.pick({
envelopeId: true,
id: true,
title: true,
order: true,
})
.extend({
data: z.instanceof(Uint8Array).optional(),
})
.array(),
directLink: TemplateDirectLinkSchema.pick({
directTemplateRecipientId: true,
enabled: true,
id: true,
token: true,
}).nullable(),
team: TeamSchema.pick({
id: true,
url: true,
}),
user: z.object({
id: z.number(),
name: z.string(),
email: z.string(),
}),
});
export type TEditorEnvelope = z.infer<typeof ZEditorEnvelopeSchema>;
export type EnvelopeEditorConfig = TEnvelopeEditorSettings & {
embeded?: {
presignToken: string;
mode: 'create' | 'edit';
onCreate?: (envelope: Omit<TEditorEnvelope, 'id'>) => void;
onUpdate?: (envelope: TEditorEnvelope) => void;
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,
},
};
+1 -1
View File
@@ -419,4 +419,4 @@ export const ZEnvelopeFieldAndMetaSchema = z.discriminatedUnion('type', [
}),
]);
type TEnvelopeFieldAndMeta = z.infer<typeof ZEnvelopeFieldAndMetaSchema>;
export type TEnvelopeFieldAndMeta = z.infer<typeof ZEnvelopeFieldAndMetaSchema>;
+162
View File
@@ -0,0 +1,162 @@
import type { EnvelopeEditorConfig, TEnvelopeEditorSettings } 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;
/**
* Takes parsed `features` from the embedding hash and an `embeded` config,
* and produces a complete `EnvelopeEditorConfig` with sensible embedded-mode defaults.
*
* Any explicitly provided feature flag overrides the embedded default.
*/
export function buildEditorConfigFromFeatures(
features: TEnvelopeEditorSettings,
embeded: EnvelopeEditorConfig['embeded'],
): EnvelopeEditorConfig {
return {
embeded,
general: {
allowConfigureEnvelopeTitle:
features.general.allowConfigureEnvelopeTitle ??
EMBEDDED_DEFAULTS.general.allowConfigureEnvelopeTitle,
allowUploadAndRecipientStep:
features.general.allowUploadAndRecipientStep ??
EMBEDDED_DEFAULTS.general.allowUploadAndRecipientStep,
allowAddFieldsStep:
features.general.allowAddFieldsStep ?? EMBEDDED_DEFAULTS.general.allowAddFieldsStep,
allowPreviewStep:
features.general.allowPreviewStep ?? EMBEDDED_DEFAULTS.general.allowPreviewStep,
minimizeLeftSidebar:
features.general.minimizeLeftSidebar ?? EMBEDDED_DEFAULTS.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,
},
actions: {
allowAttachments:
features.actions.allowAttachments ?? EMBEDDED_DEFAULTS.actions.allowAttachments,
allowDistributing:
features.actions.allowDistributing ?? EMBEDDED_DEFAULTS.actions.allowDistributing,
allowDirectLink:
features.actions.allowDirectLink ?? EMBEDDED_DEFAULTS.actions.allowDirectLink,
allowDuplication:
features.actions.allowDuplication ?? EMBEDDED_DEFAULTS.actions.allowDuplication,
allowDownloadPDF:
features.actions.allowDownloadPDF ?? EMBEDDED_DEFAULTS.actions.allowDownloadPDF,
allowDeletion: features.actions.allowDeletion ?? EMBEDDED_DEFAULTS.actions.allowDeletion,
allowReturnToPreviousPage:
features.actions.allowReturnToPreviousPage ??
EMBEDDED_DEFAULTS.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,
},
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,
},
fields: {
allowAIDetection:
features.fields?.allowAIDetection ?? EMBEDDED_DEFAULTS.fields.allowAIDetection,
},
};
}
@@ -32,6 +32,10 @@ export const createEmbeddingEnvelopeRoute = procedure
userId,
teamId,
input,
requestMetadata: ctx.metadata,
options: {
// Default recipients should be added on the frontend automatically for embeds.
bypassDefaultRecipients: true,
},
apiRequestMetadata: ctx.metadata,
});
});
@@ -32,7 +32,7 @@ export const createEnvelopeRoute = authenticatedProcedure
userId: ctx.user.id,
teamId: ctx.teamId,
input,
requestMetadata: ctx.metadata,
apiRequestMetadata: ctx.metadata,
});
});
@@ -47,14 +47,19 @@ type CreateEnvelopeRouteOptions = {
*/
teamId: number;
input: TCreateEnvelopeRequest;
requestMetadata: ApiRequestMetadata;
apiRequestMetadata: ApiRequestMetadata;
options?: {
bypassDefaultRecipients?: boolean;
};
};
export const createEnvelopeRouteCaller = async ({
userId,
teamId,
input,
requestMetadata,
apiRequestMetadata,
options = {},
}: CreateEnvelopeRouteOptions) => {
const { payload, files } = input;
@@ -116,6 +121,7 @@ export const createEnvelopeRouteCaller = async ({
flattenForm: type !== EnvelopeType.TEMPLATE,
});
// Todo: Embeds - Does this need to be done on the frontend?
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized);
const { id: documentDataId } = await putPdfFileServerSide({
@@ -188,7 +194,8 @@ export const createEnvelopeRouteCaller = async ({
},
attachments,
meta,
requestMetadata,
requestMetadata: apiRequestMetadata,
bypassDefaultRecipients: options.bypassDefaultRecipients,
});
return {
@@ -1,6 +1,7 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
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';
import { canEnvelopeItemsBeModified } from '@documenso/lib/utils/envelope';
import { prisma } from '@documenso/prisma';
@@ -57,50 +58,75 @@ export const deleteEnvelopeItemRoute = authenticatedProcedure
});
}
const result = await prisma.$transaction(async (tx) => {
const deletedEnvelopeItem = await tx.envelopeItem.delete({
where: {
id: envelopeItemId,
envelopeId: envelope.id,
},
select: {
id: true,
title: true,
documentData: {
select: {
id: true,
},
},
},
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.ENVELOPE_ITEM_DELETED,
envelopeId: envelope.id,
data: {
envelopeItemId: deletedEnvelopeItem.id,
envelopeItemTitle: deletedEnvelopeItem.title,
},
user: {
name: user.name,
email: user.email,
},
requestMetadata: metadata.requestMetadata,
}),
});
return deletedEnvelopeItem;
});
await prisma.documentData.delete({
where: {
id: result.documentData.id,
envelopeItem: {
is: null,
},
},
await UNSAFE_deleteEnvelopeItem({
envelopeId,
envelopeItemId,
user,
apiRequestMetadata: metadata,
});
return ZGenericSuccessResponse;
});
type UnsafeDeleteEnvelopeItemOptions = {
envelopeId: string;
envelopeItemId: string;
user: {
id: number;
name: string | null;
email: string;
};
apiRequestMetadata: ApiRequestMetadata;
};
export const UNSAFE_deleteEnvelopeItem = async ({
envelopeId,
envelopeItemId,
user,
apiRequestMetadata,
}: UnsafeDeleteEnvelopeItemOptions) => {
const result = await prisma.$transaction(async (tx) => {
const deletedEnvelopeItem = await tx.envelopeItem.delete({
where: {
id: envelopeItemId,
envelopeId,
},
select: {
id: true,
title: true,
documentData: {
select: {
id: true,
},
},
},
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.ENVELOPE_ITEM_DELETED,
envelopeId,
data: {
envelopeItemId: deletedEnvelopeItem.id,
envelopeItemTitle: deletedEnvelopeItem.title,
},
user: {
name: user.name,
email: user.email,
},
requestMetadata: apiRequestMetadata.requestMetadata,
}),
});
return deletedEnvelopeItem;
});
await prisma.documentData.delete({
where: {
id: result.documentData.id,
envelopeItem: {
is: null,
},
},
});
};
@@ -54,6 +54,9 @@ export const updateEnvelopeItemsRoute = authenticatedProcedure
});
}
// Note: This logic is duplicated in many places. If we plan to allow changing title/order
// even after the envelope has been sent, make sure to update it everywhere including
// embedding routes.
if (!canEnvelopeItemsBeModified(envelope, envelope.recipients)) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Envelope item is not editable',
@@ -71,30 +74,51 @@ export const updateEnvelopeItemsRoute = authenticatedProcedure
});
}
const updatedEnvelopeItems = await Promise.all(
data.map(async ({ envelopeItemId, order, title }) =>
prisma.envelopeItem.update({
where: {
envelopeId: envelope.id,
id: envelopeItemId,
},
data: {
order,
title,
},
select: {
id: true,
order: true,
title: true,
envelopeId: true,
},
}),
),
);
// Todo: Envelope [AUDIT_LOGS]
const updatedEnvelopeItems = await UNSAFE_updateEnvelopeItems({
envelopeId,
data,
});
return {
data: updatedEnvelopeItems,
};
});
type UnsafeUpdateEnvelopeItemsOptions = {
envelopeId: string;
data: {
envelopeItemId: string;
order?: number;
title?: string;
}[];
};
export const UNSAFE_updateEnvelopeItems = async ({
envelopeId,
data,
}: UnsafeUpdateEnvelopeItemsOptions) => {
// Todo: Envelope [AUDIT_LOGS]
const updatedEnvelopeItems = await Promise.all(
data.map(async ({ envelopeItemId, order, title }) =>
prisma.envelopeItem.update({
where: {
envelopeId,
id: envelopeItemId,
},
data: {
order,
title,
},
select: {
id: true,
order: true,
title: true,
envelopeId: true,
},
}),
),
);
return updatedEnvelopeItems;
};