From e2b3597c36c5748b0e338cbe5107a6270856645f Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 19 Feb 2026 11:36:36 +1100 Subject: [PATCH] fix: wip --- .../envelope-editor-title-input.tsx | 5 +- .../envelope-editor-upload-page.tsx | 59 ++- .../v2+/authoring+/envelope.create._index.tsx | 404 ++++++++++++++++++ .../v2+/authoring+/envelope.edit.$id.tsx | 353 +++++++++++++++ overview.md | 180 ++++++++ packages/lib/types/envelope-editor.ts | 153 ++++--- packages/lib/utils/embed-config.ts | 226 +++++----- .../update-embedding-envelope.ts | 389 +++++++++++++++++ .../update-embedding-envelope.types.ts | 94 ++++ .../envelope-router/create-envelope-items.ts | 304 +++++++------ trpc-upload.md | 300 +++++++++++++ 11 files changed, 2162 insertions(+), 305 deletions(-) create mode 100644 apps/remix/app/routes/embed+/v2+/authoring+/envelope.create._index.tsx create mode 100644 apps/remix/app/routes/embed+/v2+/authoring+/envelope.edit.$id.tsx create mode 100644 overview.md create mode 100644 packages/trpc/server/embedding-router/update-embedding-envelope.ts create mode 100644 packages/trpc/server/embedding-router/update-embedding-envelope.types.ts create mode 100644 trpc-upload.md diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-title-input.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-title-input.tsx index 5f389a41a..1da194c11 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-title-input.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-title-input.tsx @@ -9,6 +9,7 @@ export type EnvelopeItemTitleInputProps = { className?: string; placeholder?: string; disabled?: boolean; + dataTestId?: string; }; export const EnvelopeItemTitleInput = ({ @@ -17,6 +18,7 @@ export const EnvelopeItemTitleInput = ({ className, placeholder, disabled, + dataTestId, }: EnvelopeItemTitleInputProps) => { const [envelopeItemTitle, setEnvelopeItemTitle] = useState(value); const [isError, setIsError] = useState(false); @@ -63,6 +65,7 @@ export const EnvelopeItemTitleInput = ({ {envelopeItemTitle || placeholder} { ); const onFileDrop = async (files: File[]) => { - const newUploadingFiles: (LocalFile & { file: File; data: ArrayBuffer | null })[] = + const newUploadingFiles: (LocalFile & { file: File; data: Uint8Array | null })[] = await Promise.all( files.map(async (file) => { return { @@ -118,7 +118,7 @@ export const EnvelopeEditorUploadPage = () => { file, isUploading: isEmbedded ? false : true, // Clone the buffer so it can be read multiple times (File.arrayBuffer() consumes the stream once) - data: isEmbedded ? (await file.arrayBuffer()).slice(0) : null, + data: isEmbedded ? new Uint8Array((await file.arrayBuffer()).slice(0)) : null, isError: false, }; }), @@ -226,6 +226,30 @@ export const EnvelopeEditorUploadPage = () => { }; const debouncedUpdateEnvelopeItems = useDebounceFunction((files: LocalFile[]) => { + if (isEmbedded) { + const nextEnvelopeItems = files + .filter((item) => item.envelopeItemId) + .map((item, index) => { + const originalEnvelopeItem = envelope.envelopeItems.find( + (envelopeItem) => envelopeItem.id === item.envelopeItemId, + ); + + return { + id: item.envelopeItemId || '', + title: item.title, + order: index + 1, + envelopeId: envelope.id, + data: originalEnvelopeItem?.data, + }; + }); + + setLocalEnvelope({ + envelopeItems: nextEnvelopeItems, + }); + + return; + } + void updateEnvelopeItems({ envelopeId: envelope.id, data: files @@ -310,6 +334,7 @@ export const EnvelopeEditorUploadPage = () => { {uploadConfig?.allowUpload && ( { {(provided) => ( -
+
{localFiles.map((localFile, index) => ( { > {(provided, snapshot) => (
{ {uploadConfig?.allowConfigureOrder && (
@@ -365,6 +397,7 @@ export const EnvelopeEditorUploadPage = () => { !uploadConfig?.allowConfigureTitle } value={localFile.title} + dataTestId={`envelope-item-title-input-${localFile.id}`} placeholder={t`Document Title`} onChange={(title) => { onEnvelopeItemTitleChange(localFile.envelopeItemId!, title); @@ -399,7 +432,17 @@ export const EnvelopeEditorUploadPage = () => { {!localFile.isUploading && localFile.envelopeItemId && - uploadConfig?.allowDelete && ( + uploadConfig?.allowDelete && + (isEmbedded ? ( + + ) : ( { envelopeItemTitle={localFile.title} onDelete={onFileDelete} trigger={ - } /> - )} + ))}
)} diff --git a/apps/remix/app/routes/embed+/v2+/authoring+/envelope.create._index.tsx b/apps/remix/app/routes/embed+/v2+/authoring+/envelope.create._index.tsx new file mode 100644 index 000000000..3f91b398c --- /dev/null +++ b/apps/remix/app/routes/embed+/v2+/authoring+/envelope.create._index.tsx @@ -0,0 +1,404 @@ +import { useLayoutEffect, useMemo, useState } from 'react'; + +import { Trans } from '@lingui/react/macro'; +import { useLingui } from '@lingui/react/macro'; +import { + DocumentStatus, + EnvelopeType, + ReadStatus, + SendStatus, + SigningStatus, +} from '@prisma/client'; +import { CheckCircle2Icon } from 'lucide-react'; + +import { EnvelopeEditorProvider } from '@documenso/lib/client-only/providers/envelope-editor-provider'; +import type { SupportedLanguageCodes } from '@documenso/lib/constants/i18n'; +import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token'; +import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settings'; +import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients'; +import type { TDocumentMetaDateFormat } from '@documenso/lib/types/document-meta'; +import type { TEditorEnvelope } from '@documenso/lib/types/envelope-editor'; +import { + type TEmbedCreateEnvelopeAuthoring, + ZEmbedCreateEnvelopeAuthoringSchema, +} from '@documenso/lib/types/envelope-editor'; +import type { TEnvelopeFieldAndMeta } from '@documenso/lib/types/field-meta'; +import { extractDerivedDocumentMeta } from '@documenso/lib/utils/document'; +import { buildEmbeddedFeatures } from '@documenso/lib/utils/embed-config'; +import { buildEmbeddedEditorOptions } from '@documenso/lib/utils/embed-config'; +import { trpc } from '@documenso/trpc/react'; +import type { TCreateEnvelopePayload } from '@documenso/trpc/server/envelope-router/create-envelope.types'; +import { Spinner } from '@documenso/ui/primitives/spinner'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { EnvelopeEditor } from '~/components/general/envelope-editor/envelope-editor'; +import { EnvelopeEditorRenderProviderWrapper } from '~/components/general/envelope-editor/envelope-editor-renderer-provider-wrapper'; +import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader'; + +import type { Route } from './+types/envelope.create._index'; + +export const loader = async ({ request }: Route.LoaderArgs) => { + const url = new URL(request.url); + + // We know that the token is present because we're checking it in the parent _layout route + const token = url.searchParams.get('token') || ''; + + if (!token) { + throw new Response('Invalid token', { status: 404 }); + } + + // We also know that the token is valid, but we need the userId + teamId + const result = await verifyEmbeddingPresignToken({ token }).catch(() => null); + + if (!result) { + throw new Response('Invalid token', { status: 404 }); + } + + const teamSettings = await getTeamSettings({ + userId: result.userId, + teamId: result.teamId, + }); + + return superLoaderJson({ + token, + tokenUserId: result.userId, + tokenTeamId: result.teamId, + teamSettings, + }); +}; + +export default function EmbeddingAuthoringEnvelopeCreatePage() { + const [hasInitialized, setHasInitialized] = useState(false); + const [embedAuthoringOptions, setEmbedAuthoringOptions] = + useState(null); + + useLayoutEffect(() => { + try { + const hash = window.location.hash.slice(1); + + if (hash) { + const result = ZEmbedCreateEnvelopeAuthoringSchema.safeParse( + JSON.parse(decodeURIComponent(atob(hash))), + ); + + if (result.success) { + setEmbedAuthoringOptions({ + ...result.data, + features: buildEmbeddedFeatures(result.data.features), + }); + } + } + } catch (err) { + console.error('Error parsing embedding params:', err); + } + + setHasInitialized(true); + }, []); + + if (!hasInitialized) { + return ( +
+ +
+ ); + } + + if (!embedAuthoringOptions) { + return ( +
+ Invalid Embedding Parameters +
+ ); + } + + return ; +} + +type EnvelopeCreatePageProps = { + embedAuthoringOptions: TEmbedCreateEnvelopeAuthoring; +}; + +const EnvelopeCreatePage = ({ embedAuthoringOptions }: EnvelopeCreatePageProps) => { + const { token, tokenUserId, tokenTeamId, teamSettings } = useSuperLoaderData(); + + const { t } = useLingui(); + const { toast } = useToast(); + + const [isCreatingEnvelope, setIsCreatingEnvelope] = useState(false); + const [createdEnvelope, setCreatedEnvelope] = useState<{ id: string } | null>(null); + + const { mutateAsync: createEmbeddingEnvelope } = + trpc.embeddingPresign.createEmbeddingEnvelope.useMutation(); + + const buildCreateEnvelopeRequest = ( + envelope: Omit, + ): { payload: TCreateEnvelopePayload; files: File[] } => { + const sortedItems = [...envelope.envelopeItems].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); + + const itemIdToIndex = new Map(); + + sortedItems.forEach((item, index) => { + itemIdToIndex.set(String(item.id), index); + }); + + const files: File[] = []; + + for (const item of sortedItems) { + if (!item.data) { + throw new Error(`Envelope item "${item.title ?? item.id}" has no PDF data`); + } + + files.push( + new File( + [item.data], + item.title?.endsWith('.pdf') ? item.title : `${item.title ?? 'document'}.pdf`, + { + type: 'application/pdf', + }, + ), + ); + } + + const recipients = envelope.recipients.map((recipient) => { + const recipientFields = envelope.fields.filter((f) => f.recipientId === recipient.id); + + const fields = recipientFields.map((field) => { + return { + identifier: itemIdToIndex.get(String(field.envelopeItemId)), + page: field.page, + positionX: Number(field.positionX), + positionY: Number(field.positionY), + width: Number(field.width), + height: Number(field.height), + ...({ + type: field.type, + fieldMeta: field.fieldMeta ?? undefined, + } as TEnvelopeFieldAndMeta), + }; + }); + + return { + email: recipient.email, + name: recipient.name, + role: recipient.role, + signingOrder: recipient.signingOrder ?? undefined, + accessAuth: recipient.authOptions?.accessAuth ?? [], + actionAuth: recipient.authOptions?.actionAuth ?? [], + fields, + }; + }); + + const payload: TCreateEnvelopePayload = { + title: envelope.title, + type: envelope.type, + externalId: envelope.externalId ?? undefined, + visibility: envelope.visibility, + globalAccessAuth: envelope.authOptions?.globalAccessAuth?.length + ? envelope.authOptions?.globalAccessAuth + : undefined, + globalActionAuth: envelope.authOptions?.globalActionAuth?.length + ? envelope.authOptions?.globalActionAuth + : undefined, + folderId: envelope.folderId ?? undefined, + recipients, + meta: { + subject: envelope.documentMeta.subject ?? undefined, + message: envelope.documentMeta.message ?? undefined, + timezone: envelope.documentMeta.timezone ?? undefined, + dateFormat: (envelope.documentMeta.dateFormat as TDocumentMetaDateFormat) ?? undefined, + distributionMethod: envelope.documentMeta.distributionMethod ?? undefined, + signingOrder: envelope.documentMeta.signingOrder ?? undefined, + allowDictateNextSigner: envelope.documentMeta.allowDictateNextSigner ?? undefined, + redirectUrl: envelope.documentMeta.redirectUrl ?? undefined, + language: envelope.documentMeta.language as SupportedLanguageCodes, + typedSignatureEnabled: envelope.documentMeta.typedSignatureEnabled ?? undefined, + uploadSignatureEnabled: envelope.documentMeta.uploadSignatureEnabled ?? undefined, + drawSignatureEnabled: envelope.documentMeta.drawSignatureEnabled ?? undefined, + emailId: envelope.documentMeta.emailId ?? undefined, + emailReplyTo: envelope.documentMeta.emailReplyTo ?? undefined, + emailSettings: envelope.documentMeta.emailSettings ?? undefined, + }, + }; + + return { payload, files }; + }; + + const createEmbeddedEnvelope = async (envelopeWithoutId: Omit) => { + setIsCreatingEnvelope(true); + + if (isCreatingEnvelope) { + return; + } + + try { + const { payload, files } = buildCreateEnvelopeRequest(envelopeWithoutId); + + const formData = new FormData(); + formData.append('payload', JSON.stringify(payload)); + + for (const file of files) { + formData.append('files', file); + } + + const { id } = await createEmbeddingEnvelope(formData); + + // Send a message to the parent window with the document details + if (window.parent !== window) { + window.parent.postMessage( + { + type: 'envelope-created', + envelopeId: id, + externalId: envelopeWithoutId.externalId, + }, + '*', + ); + } + + setCreatedEnvelope({ id }); + } catch (err) { + console.error('Failed to create envelope:', err); + + toast({ + variant: 'destructive', + title: t`Error`, + description: t`Failed to create document. Please try again.`, + }); + } + + setIsCreatingEnvelope(false); + }; + + const embeded = useMemo( + () => ({ + presignToken: token, + mode: 'create' as const, + onCreate: async (envelope: Omit) => createEmbeddedEnvelope(envelope), + customBrandingLogo: Boolean(teamSettings.brandingEnabled && teamSettings.brandingLogo), + }), + [token], + ); + + const editorConfig = useMemo(() => { + return buildEmbeddedEditorOptions(embedAuthoringOptions.features, embeded); + }, [embedAuthoringOptions.features, embeded]); + + const initialEnvelope = useMemo((): TEditorEnvelope => { + const defaultDocumentMeta = extractDerivedDocumentMeta(teamSettings, undefined); + + const defaultRecipients = teamSettings.defaultRecipients + ? ZDefaultRecipientsSchema.parse(teamSettings.defaultRecipients) + : []; + + const recipients: TEditorEnvelope['recipients'] = defaultRecipients.map((recipient, index) => ({ + id: -(index + 1), + envelopeId: '', + email: recipient.email, + name: recipient.name, + role: recipient.role, + token: '', + readStatus: ReadStatus.NOT_OPENED, + signingStatus: SigningStatus.NOT_SIGNED, + sendStatus: SendStatus.NOT_SENT, + documentDeletedAt: null, + expired: null, + signedAt: null, + authOptions: { + accessAuth: [], + actionAuth: [], + }, + signingOrder: index + 1, + rejectionReason: null, + })); + + const type = embedAuthoringOptions.type; + + return { + id: '', + secondaryId: '', + internalVersion: 2, + type, + status: DocumentStatus.DRAFT, + source: 'DOCUMENT', + visibility: teamSettings.documentVisibility, + templateType: 'PRIVATE', + completedAt: null, + deletedAt: null, + title: type === EnvelopeType.DOCUMENT ? 'Document Title' : 'Template Title', + authOptions: { + globalAccessAuth: [], + globalActionAuth: [], + }, + publicTitle: '', + publicDescription: '', + userId: tokenUserId, + teamId: tokenTeamId, + folderId: null, + documentMeta: { + id: '', + ...defaultDocumentMeta, + }, + recipients, + fields: [], + envelopeItems: [], + directLink: null, + team: { + id: tokenTeamId, + url: '', + }, + user: { + id: tokenUserId, + name: '', + email: '', + }, + externalId: embedAuthoringOptions?.externalId ?? null, + }; + }, []); + + return ( +
+ {isCreatingEnvelope && ( +
+ + +

+ {initialEnvelope.type === EnvelopeType.DOCUMENT ? ( + Creating Document + ) : ( + Creating Template + )} +

+
+ )} + + {createdEnvelope && ( +
+
+ + +

+ {initialEnvelope.type === EnvelopeType.TEMPLATE ? ( + Template Created + ) : ( + Document Created + )} +

+ +

+ {initialEnvelope.type === EnvelopeType.TEMPLATE ? ( + Your template has been created successfully + ) : ( + Your document has been created successfully + )} +

+
+
+ )} + + + + + + +
+ ); +}; diff --git a/apps/remix/app/routes/embed+/v2+/authoring+/envelope.edit.$id.tsx b/apps/remix/app/routes/embed+/v2+/authoring+/envelope.edit.$id.tsx new file mode 100644 index 000000000..f82e24544 --- /dev/null +++ b/apps/remix/app/routes/embed+/v2+/authoring+/envelope.edit.$id.tsx @@ -0,0 +1,353 @@ +import { useLayoutEffect, useMemo, useState } from 'react'; + +import { Trans, useLingui } from '@lingui/react/macro'; +import { EnvelopeType } from '@prisma/client'; +import { CheckCircle2Icon } from 'lucide-react'; +import { redirect } from 'react-router'; + +import { EnvelopeEditorProvider } from '@documenso/lib/client-only/providers/envelope-editor-provider'; +import type { SupportedLanguageCodes } from '@documenso/lib/constants/i18n'; +import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token'; +import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id'; +import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settings'; +import type { TDocumentMetaDateFormat } from '@documenso/lib/types/document-meta'; +import type { TEditorEnvelope } from '@documenso/lib/types/envelope-editor'; +import { + type TEmbedEditEnvelopeAuthoring, + ZEmbedEditEnvelopeAuthoringSchema, +} from '@documenso/lib/types/envelope-editor'; +import type { TEnvelopeFieldAndMeta } from '@documenso/lib/types/field-meta'; +import { buildEmbeddedEditorOptions } from '@documenso/lib/utils/embed-config'; +import { trpc } from '@documenso/trpc/react'; +import type { TUpdateEmbeddingEnvelopePayload } from '@documenso/trpc/server/embedding-router/update-embedding-envelope.types'; +import { Spinner } from '@documenso/ui/primitives/spinner'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { EnvelopeEditor } from '~/components/general/envelope-editor/envelope-editor'; +import { EnvelopeEditorRenderProviderWrapper } from '~/components/general/envelope-editor/envelope-editor-renderer-provider-wrapper'; +import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader'; + +import type { Route } from './+types/envelope.edit.$id'; + +export const loader = async ({ request, params }: Route.LoaderArgs) => { + const url = new URL(request.url); + + const { id } = params; + + if (!id || !id.startsWith('envelope_')) { + throw redirect(`/embed/v2/authoring/error/not-found`); + } + + // We know that the token is present because we're checking it in the parent _layout route + const token = url.searchParams.get('token') || ''; + + if (!token) { + throw new Response('Invalid token', { status: 404 }); + } + + // We also know that the token is valid, but we need the userId + teamId + const result = await verifyEmbeddingPresignToken({ token, scope: `envelope:${id}` }).catch( + () => null, + ); + + if (!result) { + throw new Error('Invalid token'); + } + + const settings = await getTeamSettings({ + userId: result.userId, + teamId: result.teamId, + }); + + const envelope = await getEnvelopeById({ + id: { + type: 'envelopeId', + id, + }, + type: null, + userId: result.userId, + teamId: result.teamId, + }).catch(() => null); + + if (!envelope) { + throw redirect(`/embed/v2/authoring/error/not-found`); + } + + let brandingLogo: string | undefined = undefined; + + if (settings.brandingEnabled && settings.brandingLogo) { + brandingLogo = settings.brandingLogo; + } + + return superLoaderJson({ + token, + envelope, + brandingLogo, + }); +}; + +export default function EmbeddingAuthoringEnvelopeEditPage() { + const [hasInitialized, setHasInitialized] = useState(false); + const [embedAuthoringOptions, setEmbedAuthoringOptions] = + useState(null); + + useLayoutEffect(() => { + try { + const hash = window.location.hash.slice(1); + + if (hash) { + const result = ZEmbedEditEnvelopeAuthoringSchema.safeParse( + JSON.parse(decodeURIComponent(atob(hash))), + ); + + if (result.success) { + setEmbedAuthoringOptions(result.data); + } + } + } catch (err) { + console.error('Error parsing embedding params:', err); + } + + setHasInitialized(true); + }, []); + + if (!hasInitialized) { + return ( +
+ +
+ ); + } + + if (!embedAuthoringOptions) { + return ( +
+ Invalid Embedding Parameters +
+ ); + } + + return ; +} + +type EnvelopeEditPageProps = { + embedAuthoringOptions: TEmbedEditEnvelopeAuthoring; +}; + +const EnvelopeEditPage = ({ embedAuthoringOptions }: EnvelopeEditPageProps) => { + const { envelope, token, brandingLogo } = useSuperLoaderData(); + + const { t } = useLingui(); + const { toast } = useToast(); + + const [isUpdatingEnvelope, setIsUpdatingEnvelope] = useState(false); + const [updatedEnvelope, setUpdatedEnvelope] = useState<{ id: string } | null>(null); + + const { mutateAsync: updateEmbeddingEnvelope } = + trpc.embeddingPresign.updateEmbeddingEnvelope.useMutation(); + + const buildUpdateEnvelopeRequest = ( + envelope: TEditorEnvelope, + ): { payload: TUpdateEmbeddingEnvelopePayload; files: File[] } => { + const files: File[] = []; + + const envelopeItems = envelope.envelopeItems.map((item) => { + // Attach any new envelope item files to the request. + if (item.data) { + files.push( + new File( + [item.data], + item.title?.endsWith('.pdf') ? item.title : `${item.title ?? 'document'}.pdf`, + { + type: 'application/pdf', + }, + ), + ); + } + + return { + id: item.id, + title: item.title, + order: item.order, + index: item.data ? files.length - 1 : undefined, // Todo: Embed why not use the ID instead? + }; + }); + + const recipients = envelope.recipients.map((recipient) => { + const recipientFields = envelope.fields.filter((f) => f.recipientId === recipient.id); + + const fields = recipientFields.map((field) => ({ + id: field.id, + envelopeItemId: field.envelopeItemId, + page: field.page, + positionX: Number(field.positionX), + positionY: Number(field.positionY), + width: Number(field.width), + height: Number(field.height), + ...({ + type: field.type, + fieldMeta: field.fieldMeta ?? undefined, + } as TEnvelopeFieldAndMeta), + })); + + return { + email: recipient.email, + name: recipient.name, + role: recipient.role, + signingOrder: recipient.signingOrder ?? undefined, + accessAuth: recipient.authOptions?.accessAuth ?? [], + actionAuth: recipient.authOptions?.actionAuth ?? [], + fields, + }; + }); + + const payload: TUpdateEmbeddingEnvelopePayload = { + envelopeId: envelope.id, + data: { + title: envelope.title, + externalId: envelope.externalId, + visibility: envelope.visibility, + globalAccessAuth: envelope.authOptions?.globalAccessAuth, + globalActionAuth: envelope.authOptions?.globalActionAuth, + folderId: envelope.folderId, + recipients, + envelopeItems, + }, + meta: { + subject: envelope.documentMeta.subject ?? undefined, + message: envelope.documentMeta.message ?? undefined, + timezone: envelope.documentMeta.timezone ?? undefined, + distributionMethod: envelope.documentMeta.distributionMethod ?? undefined, + signingOrder: envelope.documentMeta.signingOrder ?? undefined, + allowDictateNextSigner: envelope.documentMeta.allowDictateNextSigner ?? undefined, + redirectUrl: envelope.documentMeta.redirectUrl ?? undefined, + typedSignatureEnabled: envelope.documentMeta.typedSignatureEnabled ?? undefined, + uploadSignatureEnabled: envelope.documentMeta.uploadSignatureEnabled ?? undefined, + drawSignatureEnabled: envelope.documentMeta.drawSignatureEnabled ?? undefined, + emailId: envelope.documentMeta.emailId ?? undefined, + emailReplyTo: envelope.documentMeta.emailReplyTo ?? undefined, + emailSettings: envelope.documentMeta.emailSettings ?? undefined, + dateFormat: (envelope.documentMeta.dateFormat as TDocumentMetaDateFormat) ?? undefined, + language: envelope.documentMeta.language as SupportedLanguageCodes, + }, + }; + + return { payload, files }; + }; + + const updateEmbeddedEnvelope = async (envelope: TEditorEnvelope) => { + setIsUpdatingEnvelope(true); + + if (isUpdatingEnvelope) { + return; + } + + try { + const { payload, files } = buildUpdateEnvelopeRequest(envelope); + + const formData = new FormData(); + formData.append('payload', JSON.stringify(payload)); + + for (const file of files) { + formData.append('files', file); + } + + await updateEmbeddingEnvelope(formData); + + // Send a message to the parent window with the document details + if (window.parent !== window) { + window.parent.postMessage( + { + type: 'envelope-updated', + envelopeId: envelope.id, + externalId: envelope.externalId || null, + }, + '*', + ); + } + + // Navigate to the completion page. + setUpdatedEnvelope({ id: envelope.id }); + } catch (err) { + console.error('Failed to update envelope:', err); + + toast({ + variant: 'destructive', + title: t`Error`, + description: t`Failed to update envelope. Please try again.`, + }); + } + + setIsUpdatingEnvelope(false); + }; + + const embeded = useMemo( + () => ({ + presignToken: token, + mode: 'edit' as const, + onUpdate: async (envelope: TEditorEnvelope) => updateEmbeddedEnvelope(envelope), + brandingLogo, + }), + [token], + ); + + const editorConfig = useMemo(() => { + return buildEmbeddedEditorOptions(embedAuthoringOptions.features, embeded); + }, [embedAuthoringOptions.features, embeded]); + + const initialEnvelope = useMemo( + () => ({ + ...envelope, + externalId: embedAuthoringOptions?.externalId || envelope.externalId || null, + }), + [envelope, embedAuthoringOptions?.externalId], + ); + + return ( +
+ {isUpdatingEnvelope && ( +
+ + +

+ {envelope.type === EnvelopeType.DOCUMENT ? ( + Updating Document + ) : ( + Updating Template + )} +

+
+ )} + + {updatedEnvelope && ( +
+
+ + +

+ {envelope.type === EnvelopeType.TEMPLATE ? ( + Template Updated + ) : ( + Document Updated + )} +

+ +

+ {envelope.type === EnvelopeType.TEMPLATE ? ( + Your template has been updated successfully + ) : ( + Your document has been updated successfully + )} +

+
+
+ )} + + + + + + +
+ ); +}; diff --git a/overview.md b/overview.md new file mode 100644 index 000000000..e08221a58 --- /dev/null +++ b/overview.md @@ -0,0 +1,180 @@ +# V2 Envelope Embedding System — Overview + +## Architecture + +The V2 embedding system replaces the V1 iframe-based document/template authoring system with a unified **"envelope"**-based model. Instead of 4 separate components (`EmbedCreateDocumentV1`, `EmbedCreateTemplateV1`, `EmbedUpdateDocumentV1`, `EmbedUpdateTemplateV1`), V2 unifies everything around an "envelope" that can contain multiple PDF items, recipients, and fields. + +### Key Concepts + +- **Envelope**: A container holding one or more PDF documents, recipients, and fields. Replaces the V1 single-document model. +- **Presign Token**: Short-lived token exchanged from an API key, scoped to specific operations (e.g., `envelope:{id}`). Used for iframe authentication instead of session cookies. +- **Feature Flags (Hash Config)**: JSON config passed via URL hash fragment to control which UI elements are visible/editable in the embedded editor. 5 categories with ~25 flags. +- **Local Mode**: In embedded create mode, all mutations (add recipient, add field, etc.) happen locally with auto-incrementing negative IDs — no network calls until the final "create" action. +- **EditorConfig**: Runtime config object built from feature flags that controls the editor's behavior (which steps to show, which actions to allow, which settings to render). + +--- + +## Files Changed (~40 files) + +### New Routes (`apps/remix/app/routes/embed+/`) + +| File | Purpose | +| ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `v2+/authoring+/_layout.tsx` | Layout/auth wrapper. Verifies presign tokens, sets up fake team/org providers (hardcoded team ID=1, org ID='123'), injects CSS/cssVars for white-labeling, wraps children in `TrpcProvider`, `LimitsProvider` (with `bypassLimits=true`), `OrganisationProvider`, `TeamProvider`. | +| `v2+/authoring+/envelope.create._index.tsx` | Create envelope page. Loads team settings for defaults, builds blank `TEditorEnvelope`, parses feature flags from URL hash, handles `createEmbeddingEnvelope` mutation (FormData with payload + files), posts `envelope-created` message to parent window. | +| `v2+/authoring+/envelope.edit.$id.tsx` | Edit envelope page. Loads existing envelope by ID, verifies presign token with scope `envelope:{id}`. **Currently broken — won't compile** (see Issues section). | +| `v2+/authoring_.completed.create.tsx` | Completion page. Marked as "not being used" via Todo comment. | +| `v2+/multisign+/_index.tsx` | Multi-document signing view. Lists multiple documents for a recipient to sign sequentially. Handles `document-completed`, `document-rejected`, `document-error`, `document-ready`, `all-documents-completed` postMessage events. | +| `dummy.tsx` | Internal test/dev page (606 lines). Full control panel with all feature flags, CSS theming, token exchange (api\_ -> presign token), iframe rendering, and postMessage monitoring. | + +### New Types & Utils (`packages/lib/`) + +| File | Purpose | +| --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `types/envelope-editor.ts` | Zod schemas: `ZBaseEmbedAuthoringFeaturesSchema` (5 feature categories), `ZBaseEmbedAuthoringSchema`, `ZBaseEmbedAuthoringEditSchema` (adds `onlyEditFields`), `ZEditorEnvelopeSchema`. | +| `utils/embed-config.ts` | `buildEditorConfigFromFeatures()` — maps parsed feature flags to `EnvelopeEditorConfig` with sensible defaults for embedded mode (e.g., `minimizeLeftSidebar: true`, most actions disabled). | +| `client-only/hooks/use-editor-query.ts` | Dual-mode hook (`trpc` vs `local`). Local mode simulates mutations with auto-incrementing negative IDs, no network calls. **Currently unused** — provider still uses direct trpc mutations with `isEmbedded` checks. | + +### Modified Providers (`packages/lib/client-only/providers/`) + +| File | Changes | +| ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `envelope-editor-provider.tsx` | Added `EnvelopeEditorConfig` type (5 config sections + `embeded` config). Added `isEmbedded` flag, local-only mapping functions (`mapLocalRecipientsToRecipients`, `mapLocalFieldsToFields`), embedded path handling, `flushAutosave()`. When embedded, skips trpc mutations and maps locally. | +| `envelope-render-provider.tsx` | Added `presignToken` prop, local file rendering via `pdfToImagesClientSide` when envelope ID is empty (embedded create mode), handling of `PRESIGNED_` prefixed items. | + +### TRPC Changes (`packages/trpc/server/embedding-router/`) + +| File | Purpose | +| ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `_router.ts` | Added `createEmbeddingEnvelope` route registration. | +| `create-embedding-envelope.ts` | New mutation. Accepts FormData (payload JSON + files), verifies presign token, normalizes PDFs, extracts placeholders, uploads to storage, creates envelope. Duplicated from `create-envelope.ts` with presign auth instead of session auth. | +| `create-embedding-envelope.types.ts` | Reuses `ZCreateEnvelopePayloadSchema`, wraps in `zodFormData`. | +| `create-embedding-presign-token.types.ts` | Added `scope` field to presign token request (e.g., `envelope:envelope_123`). | + +### Modified Components (`apps/remix/app/components/`) + +| File | Changes | +| ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `envelope-editor.tsx` | Reads `editorConfig` for which steps/actions to show/hide. Respects `minimizeLeftSidebar`, conditional rendering of quick actions. | +| `envelope-editor-header.tsx` | Added embedded create/update buttons that call `flushAutosave()` then `embeded.onCreate/onUpdate`. Conditionally shows branding, attachments, settings based on config. | +| `envelope-editor-upload-page.tsx` | When `isEmbedded`, files are stored locally as `Uint8Array` on `envelopeItems` with `PRESIGNED_` prefix IDs instead of being uploaded immediately. | +| `envelope-editor-recipient-form.tsx` | Reads `editorConfig.recipients` to hide/show role options (approver, viewer, CC, assistant), signing order, dictate next signer. | +| `envelope-editor-settings-dialog.tsx` | Reads `editorConfig.settings` to conditionally render each setting section. | +| `envelope-editor-renderer-provider-wrapper.tsx` | **New.** Wrapper that passes `presignedToken` to `EnvelopeRenderProvider`. | +| `envelope-delete-dialog.tsx` | **New.** Delete dialog for envelopes. | +| `envelope-distribute-dialog.tsx` | Minor changes to use `useCurrentEnvelopeEditor`. | +| `document-attachments-popover.tsx` | Changed to use string envelope IDs. | +| `recipient-role-select.tsx` | Added `hideAssistantRole`, `hideCCerRole`, `hideViewerRole`, `hideApproverRole` props. | +| `add-template-placeholder-recipients.tsx` | Added role hiding props passthrough. | +| `limits/provider/client.tsx` | Added `bypassLimits` prop to skip limit fetching for embedded mode. | + +### Feature Flag Schema + +5 categories with ~25 flags controlling embedded editor behavior: + +``` +general: + allowConfigureSigningOrder # Show signing order controls + allowPersonalizeRecipient # Allow recipient customization + +settings: + showGeneralSettings # General settings section + showSigningSettings # Signing-specific settings + showAdvancedSettings # Advanced settings section + showNotificationSettings # Notification preferences + showAccessSettings # Access/permission settings + +actions: + allowDistributeAction # Show distribute button + allowDirectLinkAction # Show direct link button + allowDuplicateAction # Show duplicate button + allowDownloadAction # Show download button + allowDeleteAction # Show delete button + allowReturnAction # Show return/back button + allowAttachments # Show attachments popover + +envelopeItems: + allowAdd # Add new PDFs + allowDelete # Remove PDFs + allowConfigureTitle # Edit PDF titles + allowConfigureOrder # Reorder PDFs + +recipients: + showApproverRole # Show approver role option + showViewerRole # Show viewer role option + showCCRole # Show CC role option + showAssistantRole # Show assistant role option + showSigningOrder # Show signing order controls + showDictateNextSigner # Show dictate next signer +``` + +--- + +## Issues & Missing Pieces + +### Critical — Won't Compile + +**`envelope.edit.$id.tsx`** has the following problems: + +1. References `TUpdateEnvelopePayload` and `TUpdateEmbeddingDocumentPayload` — **types don't exist**. +2. Uses `trpc.embeddingPresign.updateEmbeddingDocument.useMutation()` — **mutation doesn't exist**. There is no `update-embedding-envelope.ts` in the embedding router. +3. Missing imports: `EnvelopeType`, `Trans`. +4. `buildUpdateEnvelopeRequest` returns `{ payload: TUpdateEnvelopePayload, files: File[] }` but `TUpdateEnvelopePayload` is undefined. + +**Fix required:** Create `update-embedding-envelope.ts` + types in the trpc embedding router, register in `_router.ts`, fix all imports. + +### Explicit TODOs in Code + +| Location | Todo | +| --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `_layout.tsx` | Session provider missing — any `useSession()` call will crash. Need E2E tests. | +| `envelope.create._index.tsx` | Presign token scope handling for create mode. | +| `envelope.edit.$id.tsx` | Handle `onlyEditFields` (skip to fields step). Check externalId handling on postMessage. | +| `authoring_.completed.create.tsx` | Not being used — dead code. | +| `envelope-editor-provider.tsx` | `setEnvelope` wrapper ("WTf"). Embedded path handling needs more thought. `authOptions: null` not mapped in local mode. `Prisma.Decimal` used client-side may have bundle/compat issues. | +| `envelope-render-provider.tsx` | `PRESIGNED_` items with data logged but not handled. | +| `envelope-editor-header.tsx` | Logo link and white-label not properly handled. | +| `envelope-editor.tsx` | Delete action navigation in embedded mode unclear. | + +### Missing Features + +| Feature | Status | +| --------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| `updateEmbeddingEnvelope` mutation | Not created — edit flow has no backend endpoint | +| `use-editor-query.ts` hook integration | Created but unused — provider still manually branches on `isEmbedded` | +| E2E tests | None exist | +| V2 React SDK components (`@documenso/embed-react`) | V1 had wrapper components; no V2 equivalents | +| Template support | Code references `EnvelopeType.TEMPLATE` but routes hardcode `DOCUMENT` | +| Error route (`/embed/v2/authoring/error/not-found`) | Does not exist | +| `onlyEditFields` | Parsed but not implemented | +| Session provider in embed layout | Missing — `useSession()` calls will crash | +| White-label branding | Logo/link hardcoded in header | +| Attachments in create mode | `DocumentAttachmentsPopover` queries with empty envelope ID | +| postMessage origin security | All calls use `'*'` as target origin | +| Documentation | V1 docs at `apps/documentation/pages/developers/embedded-authoring.mdx` not updated for V2 | + +### Feature Flags Not Fully Enforced + +| Flag | Issue | +| ----------------------------------- | --------------------------------------------------------------------------------------- | +| `envelopeItems.allowConfigureTitle` | Upload page has title input but doesn't check this flag | +| `envelopeItems.allowConfigureOrder` | Drag reordering doesn't check this flag | +| `envelopeItems.allowDelete` | Delete button uses `canItemsBeModified` from envelope status, not this flag | +| `actions.allowAttachments` | Checked in header but attachments popover won't work without envelope ID in create mode | + +--- + +## Priority Order for Next Steps + +1. **Fix edit flow** — Create `update-embedding-envelope` mutation + types, fix imports in `envelope.edit.$id.tsx` +2. **Wire up `use-editor-query.ts`** — Replace manual `isEmbedded` branching in `envelope-editor-provider.tsx` +3. **Handle session provider** — Add fake session provider or ensure all components use `useOptionalSession()` +4. **Implement `onlyEditFields`** — Skip to fields step when flag is set +5. **Make envelope type configurable** — Support `TEMPLATE` via hash params or presign token scope +6. **Create error route** — `/embed/v2/authoring/error/not-found` +7. **Enforce all feature flags** — Wire remaining flags into upload page components +8. **Fix authOptions mapping** — Map recipient auth options in local mode +9. **Create V2 React SDK components** — `@documenso/embed-react` wrappers for iframe + postMessage +10. **Add E2E tests** — Cover create, edit, multi-sign flows +11. **Update documentation** — Reflect V2 API in developer docs +12. **PostMessage security** — Replace `'*'` with proper target origin diff --git a/packages/lib/types/envelope-editor.ts b/packages/lib/types/envelope-editor.ts index 068ec5530..5c7b7e854 100644 --- a/packages/lib/types/envelope-editor.ts +++ b/packages/lib/types/envelope-editor.ts @@ -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; +/** + * 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; @@ -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, - }, -}; diff --git a/packages/lib/utils/embed-config.ts b/packages/lib/utils/embed-config.ts index 9740eeee6..e6a745311 100644 --- a/packages/lib/utils/embed-config.ts +++ b/packages/lib/utils/embed-config.ts @@ -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 extends object ? { [K in keyof T]?: DeepPartial } : 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, embeded: EnvelopeEditorConfig['embeded'], ): EnvelopeEditorConfig { return { embeded, + ...buildEmbeddedFeatures(features), + }; +} +export const buildEmbeddedFeatures = ( + features: DeepPartial, +): 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, }, }; -} +}; diff --git a/packages/trpc/server/embedding-router/update-embedding-envelope.ts b/packages/trpc/server/embedding-router/update-embedding-envelope.ts new file mode 100644 index 000000000..9afb24604 --- /dev/null +++ b/packages/trpc/server/embedding-router/update-embedding-envelope.ts @@ -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 = {}; + + // 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; +}; diff --git a/packages/trpc/server/embedding-router/update-embedding-envelope.types.ts b/packages/trpc/server/embedding-router/update-embedding-envelope.types.ts new file mode 100644 index 000000000..1a7c0f3e0 --- /dev/null +++ b/packages/trpc/server/embedding-router/update-embedding-envelope.types.ts @@ -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; +export type TUpdateEmbeddingEnvelopeRequest = z.infer; diff --git a/packages/trpc/server/envelope-router/create-envelope-items.ts b/packages/trpc/server/envelope-router/create-envelope-items.ts index 5300e9b44..8c6c478b3 100644 --- a/packages/trpc/server/envelope-router/create-envelope-items.ts +++ b/packages/trpc/server/envelope-router/create-envelope-items.ts @@ -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, + }; + }); + }); +}; diff --git a/trpc-upload.md b/trpc-upload.md new file mode 100644 index 000000000..22d3fc725 --- /dev/null +++ b/trpc-upload.md @@ -0,0 +1,300 @@ +# tRPC File Upload Flow (Documenso) + +This document explains how Documenso uploads files via tRPC using `multipart/form-data`, from client to server validation and persistence. + +## 1) Client: send `FormData` to a tRPC mutation + +In `apps/remix/app/components/general/envelope/envelope-upload-button.tsx`, the UI builds a `FormData` payload and calls the mutation directly: + +- `payload` is appended as a JSON string. +- each file is appended with the same key (`files`) so it becomes a repeatable field. + +Pattern: + +```ts +const formData = new FormData(); +formData.append('payload', JSON.stringify(payload)); + +for (const file of files) { + formData.append('files', file); +} + +await createEnvelope(formData); +``` + +Important details: + +- The mutation call is `trpc.envelope.create.useMutation()` and accepts `FormData` for this route. +- The client also does pre-check UX (limits, max files, size messaging), but server still enforces authoritative validation. + +## 2) Route contract: multipart + zod-form-data + +In `packages/trpc/server/envelope-router/create-envelope.types.ts`: + +- OpenAPI metadata explicitly marks the route as multipart: + - `contentTypes: ['multipart/form-data']` +- request schema uses a custom `zodFormData(...)` wrapper. +- `payload` is parsed from JSON with `zfd.json(...)`. +- `files` is parsed as repeated files with `zfd.repeatableOfType(zfd.file())`. + +Pattern: + +```ts +export const ZCreateEnvelopeRequestSchema = zodFormData({ + payload: zfd.json(ZCreateEnvelopePayloadSchema), + files: zfd.repeatableOfType(zfd.file()), +}); +``` + +This gives a strongly typed input: + +- `input.payload` is a validated object. +- `input.files` is a validated `File[]`. + +## 3) Why `zodFormData` exists + +In `packages/trpc/utils/zod-form-data.ts`, `zodFormData` is a thin preprocess helper: + +- if input is `FormData`, it converts it into a plain object. +- duplicate keys become arrays (`getAll(key)` behavior). +- then runs `z.object(schema)` validation. + +Reason in code comments: + +- it replaces `zfd.formData()` due to pipeline/openapi edge cases where `undefined` can surface and break parsing. + +So this helper is a compatibility layer that still behaves like normal form-data parsing for Zod. + +Full file: + +```ts +import type { ZodRawShape } from 'zod'; +import z from 'zod'; + +/** + * This helper takes the place of the `z.object` at the root of your schema. + * It wraps your schema in a `z.preprocess` that extracts all the data out of a `FormData` + * and transforms it into a regular object. + * If the `FormData` contains multiple entries with the same field name, + * it will automatically turn that field into an array. + * + * This is used instead of `zfd.formData()` because it receives `undefined` + * somewhere in the pipeline of our openapi schema generation and throws + * an error. This provides the same functionality as `zfd.formData()` but + * can be considered somewhat safer. + */ +export const zodFormData = (schema: T) => { + return z.preprocess((data) => { + if (data instanceof FormData) { + const formData: Record = {}; + + for (const key of data.keys()) { + const values = data.getAll(key); + + formData[key] = values.length > 1 ? values : values[0]; + } + + return formData; + } + + return data; + }, z.object(schema)); +}; +``` + +## 4) Server mutation: validate and process each uploaded file + +In `packages/trpc/server/envelope-router/create-envelope.ts`: + +1. input is already schema-validated (`.input(ZCreateEnvelopeRequestSchema)`). +2. server enforces limits and file rules: + - monthly doc limit + - max envelope item count + - MIME must start with `application/pdf` +3. each uploaded file is processed: + - convert to buffer via `await file.arrayBuffer()` + - optionally inject form values into PDF + - normalize PDF + - extract placeholders + - upload file bytes server-side (`putPdfFileServerSide`) +4. resulting uploaded file IDs (`documentDataId`) are attached to envelope items. +5. envelope is created with those items + recipient mapping logic. + +Key loop: + +```ts +const envelopeItems = await Promise.all( + files.map(async (file) => { + let pdf = Buffer.from(await file.arrayBuffer()); + // ... optional transform + normalize + placeholder extraction + const { id: documentDataId } = await putPdfFileServerSide({ + name: file.name, + type: 'application/pdf', + arrayBuffer: async () => Promise.resolve(cleanedPdf), + }); + return { title: file.name, documentDataId, placeholders }; + }), +); +``` + +## 5) Multipart parsing infrastructure (critical) + +The multipart body support is implemented in `packages/trpc/utils/openapi-fetch-handler.ts`. + +For multipart requests, it: + +- reads `req.formData()`, +- converts entries into a plain object (accumulating repeated keys into arrays), +- supports `key[]` sent by some SDKs by normalizing to `key`, +- rewrites request `content-type` to `application/json` for the OpenAPI node handler interop, +- and passes parsed body downstream. + +This is why multipart routes can be validated by normal Zod/tRPC schemas in this codebase. + +Key multipart handling code: + +```ts +const CONTENT_TYPE_JSON = 'application/json'; +const CONTENT_TYPE_URLENCODED = 'application/x-www-form-urlencoded'; +const CONTENT_TYPE_MULTIPART = 'multipart/form-data'; + +const getMultipartBody = async (req: Request) => { + const formData = await req.formData(); + + const data: Record = {}; + + for (const [key, value] of formData.entries()) { + // !: Handles cases where our generated SDKs send key[] syntax for arrays. + const normalizedKey = key.endsWith('[]') ? key.slice(0, -2) : key; + + if (data[normalizedKey] === undefined) { + data[normalizedKey] = value; + } else if (Array.isArray(data[normalizedKey])) { + data[normalizedKey].push(value); + } else { + data[normalizedKey] = [data[normalizedKey], value]; + } + } + + return data; +}; + +const getRequestBody = async (req: Request) => { + try { + const contentType = req.headers.get('content-type') || ''; + + if (contentType.includes(CONTENT_TYPE_JSON)) { + return { + isValid: true, + // Use JSON.parse instead of req.json() because req.json() does not throw on invalid JSON + data: JSON.parse(await req.text()), + }; + } + + if (contentType.includes(CONTENT_TYPE_URLENCODED)) { + return { + isValid: true, + data: await getUrlEncodedBody(req), + }; + } + + // Handle multipart/form-data by parsing as FormData and converting to a plain object. + // This mirrors how URL-encoded data is structured, allowing tRPC to validate it normally. + // The content-type header is rewritten to application/json later via the request proxy + // because createOpenApiNodeHttpHandler aborts on any bodied request that isn't application/json. + if (contentType.includes(CONTENT_TYPE_MULTIPART)) { + return { + isValid: true, + data: await getMultipartBody(req), + }; + } + + return { + isValid: true, + data: req.body, + }; + } catch (err) { + return { + isValid: false, + cause: err, + }; + } +}; +``` + +Header rewrite and proxy behavior: + +```ts +const createRequestProxy = async (req: Request, url?: string) => { + const body = await getRequestBody(req); + + const originalContentType = req.headers.get('content-type') || ''; + const isMultipart = originalContentType.includes(CONTENT_TYPE_MULTIPART); + + return new Proxy(req, { + get: (target, prop) => { + switch (prop) { + case 'url': + return url ?? target.url; + + case 'body': { + if (!body.isValid) { + throw new TRPCError({ + code: 'PARSE_ERROR', + message: 'Failed to parse request body', + cause: body.cause, + }); + } + + return body.data; + } + + case 'headers': { + const headers = new Headers(target.headers); + + // Rewrite content-type header for multipart requests to application/json. + // This is necessary because `createOpenApiNodeHttpHandler` aborts on any bodied + // request that isn't application/json. Since we've already parsed the multipart + // data into a plain object above, this is safe to do. + if (isMultipart) { + headers.set('content-type', CONTENT_TYPE_JSON); + } + + return headers; + } + + default: + return (target as unknown as Record)[prop]; + } + }, + }); +}; +``` + +## 6) Porting checklist for another project + +Use this exact checklist: + +1. **Client mutation** + - Build `FormData`. + - Append structured data as JSON string (for example `payload`). + - Append each file under a repeatable field key (for example `files`). +2. **Route meta** + - Mark route with `contentTypes: ['multipart/form-data']`. +3. **Schema** + - Parse JSON field with `zfd.json(...)`. + - Parse repeated files with `zfd.repeatableOfType(zfd.file())`. + - Wrap root with a form-data preprocessor (`zodFormData` pattern). +4. **Request adapter** + - Ensure your server adapter can parse multipart into plain object + file values before schema validation. +5. **Server safety checks** + - Enforce limits/count/type server-side, not just UI. +6. **File processing** + - Read each file (`arrayBuffer`), transform as needed, upload, persist returned storage IDs. + +## 7) Common pitfalls + +- Relying only on client-side file restrictions. +- Forgetting repeatable parsing for multiple files. +- Missing multipart handling in the HTTP/OpenAPI adapter layer. +- Using mismatched field names between client FormData and Zod schema keys.