mirror of
https://github.com/documenso/documenso.git
synced 2026-07-15 07:17:09 +10:00
fix: wip
This commit is contained in:
@@ -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}
|
||||
</span>
|
||||
<input
|
||||
data-testid={dataTestId}
|
||||
data-1p-ignore
|
||||
autoComplete="off"
|
||||
ref={inputRef}
|
||||
@@ -72,7 +75,7 @@ export const EnvelopeItemTitleInput = ({
|
||||
disabled={disabled}
|
||||
style={{ width: `${inputWidth}px` }}
|
||||
className={cn(
|
||||
'text-foreground hover:outline-muted-foreground focus:outline-muted-foreground rounded-sm border-0 bg-transparent p-1 text-sm font-medium outline-none hover:outline hover:outline-1 focus:outline focus:outline-1',
|
||||
'rounded-sm border-0 bg-transparent p-1 text-sm font-medium text-foreground outline-none hover:outline hover:outline-1 hover:outline-muted-foreground focus:outline focus:outline-1 focus:outline-muted-foreground',
|
||||
className,
|
||||
{
|
||||
'outline-red-500': isError,
|
||||
|
||||
@@ -108,7 +108,7 @@ export const EnvelopeEditorUploadPage = () => {
|
||||
);
|
||||
|
||||
const onFileDrop = async (files: File[]) => {
|
||||
const newUploadingFiles: (LocalFile & { file: File; data: ArrayBuffer | null })[] =
|
||||
const newUploadingFiles: (LocalFile & { file: File; data: Uint8Array<ArrayBuffer> | 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 = () => {
|
||||
<CardContent>
|
||||
{uploadConfig?.allowUpload && (
|
||||
<DocumentDropzone
|
||||
data-testid="envelope-item-dropzone"
|
||||
onDrop={onFileDrop}
|
||||
allowMultiple
|
||||
className="pb-4 pt-6"
|
||||
@@ -326,7 +351,12 @@ export const EnvelopeEditorUploadPage = () => {
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<Droppable droppableId="files">
|
||||
{(provided) => (
|
||||
<div {...provided.droppableProps} ref={provided.innerRef} className="space-y-2">
|
||||
<div
|
||||
data-testid="envelope-items-list"
|
||||
{...provided.droppableProps}
|
||||
ref={provided.innerRef}
|
||||
className="space-y-2"
|
||||
>
|
||||
{localFiles.map((localFile, index) => (
|
||||
<Draggable
|
||||
key={localFile.id}
|
||||
@@ -340,6 +370,7 @@ export const EnvelopeEditorUploadPage = () => {
|
||||
>
|
||||
{(provided, snapshot) => (
|
||||
<div
|
||||
data-testid={`envelope-item-row-${localFile.id}`}
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
style={provided.draggableProps.style}
|
||||
@@ -351,6 +382,7 @@ export const EnvelopeEditorUploadPage = () => {
|
||||
{uploadConfig?.allowConfigureOrder && (
|
||||
<div
|
||||
{...provided.dragHandleProps}
|
||||
data-testid={`envelope-item-drag-handle-${localFile.id}`}
|
||||
className="cursor-grab active:cursor-grabbing"
|
||||
>
|
||||
<GripVerticalIcon className="h-5 w-5 flex-shrink-0 opacity-40" />
|
||||
@@ -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 ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
data-testid={`envelope-item-remove-button-${localFile.id}`}
|
||||
onClick={() => onFileDelete(localFile.envelopeItemId!)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<EnvelopeItemDeleteDialog
|
||||
canItemBeDeleted={canItemsBeModified}
|
||||
envelopeId={envelope.id}
|
||||
@@ -407,12 +450,16 @@ export const EnvelopeEditorUploadPage = () => {
|
||||
envelopeItemTitle={localFile.title}
|
||||
onDelete={onFileDelete}
|
||||
trigger={
|
||||
<Button variant="ghost" size="sm">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
data-testid={`envelope-item-remove-button-${localFile.id}`}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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<TEmbedCreateEnvelopeAuthoring | null>(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 (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!embedAuthoringOptions) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Trans>Invalid Embedding Parameters</Trans>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <EnvelopeCreatePage embedAuthoringOptions={embedAuthoringOptions} />;
|
||||
}
|
||||
|
||||
type EnvelopeCreatePageProps = {
|
||||
embedAuthoringOptions: TEmbedCreateEnvelopeAuthoring;
|
||||
};
|
||||
|
||||
const EnvelopeCreatePage = ({ embedAuthoringOptions }: EnvelopeCreatePageProps) => {
|
||||
const { token, tokenUserId, tokenTeamId, teamSettings } = useSuperLoaderData<typeof loader>();
|
||||
|
||||
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<TEditorEnvelope, 'id'>,
|
||||
): { payload: TCreateEnvelopePayload; files: File[] } => {
|
||||
const sortedItems = [...envelope.envelopeItems].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||
|
||||
const itemIdToIndex = new Map<string, number>();
|
||||
|
||||
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<TEditorEnvelope, 'id'>) => {
|
||||
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<TEditorEnvelope, 'id'>) => 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 (
|
||||
<div className="min-w-screen relative min-h-screen">
|
||||
{isCreatingEnvelope && (
|
||||
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center bg-background">
|
||||
<Spinner />
|
||||
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{initialEnvelope.type === EnvelopeType.DOCUMENT ? (
|
||||
<Trans>Creating Document</Trans>
|
||||
) : (
|
||||
<Trans>Creating Template</Trans>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{createdEnvelope && (
|
||||
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center bg-background">
|
||||
<div className="mx-auto w-full max-w-md text-center">
|
||||
<CheckCircle2Icon className="mx-auto h-16 w-16 text-primary" />
|
||||
|
||||
<h1 className="mt-6 text-2xl font-bold">
|
||||
{initialEnvelope.type === EnvelopeType.TEMPLATE ? (
|
||||
<Trans>Template Created</Trans>
|
||||
) : (
|
||||
<Trans>Document Created</Trans>
|
||||
)}
|
||||
</h1>
|
||||
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
{initialEnvelope.type === EnvelopeType.TEMPLATE ? (
|
||||
<Trans>Your template has been created successfully</Trans>
|
||||
) : (
|
||||
<Trans>Your document has been created successfully</Trans>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<EnvelopeEditorProvider initialEnvelope={initialEnvelope} editorConfig={editorConfig}>
|
||||
<EnvelopeEditorRenderProviderWrapper presignedToken={token}>
|
||||
<EnvelopeEditor />
|
||||
</EnvelopeEditorRenderProviderWrapper>
|
||||
</EnvelopeEditorProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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<TEmbedEditEnvelopeAuthoring | null>(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 (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!embedAuthoringOptions) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Trans>Invalid Embedding Parameters</Trans>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <EnvelopeEditPage embedAuthoringOptions={embedAuthoringOptions} />;
|
||||
}
|
||||
|
||||
type EnvelopeEditPageProps = {
|
||||
embedAuthoringOptions: TEmbedEditEnvelopeAuthoring;
|
||||
};
|
||||
|
||||
const EnvelopeEditPage = ({ embedAuthoringOptions }: EnvelopeEditPageProps) => {
|
||||
const { envelope, token, brandingLogo } = useSuperLoaderData<typeof loader>();
|
||||
|
||||
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 (
|
||||
<div className="min-w-screen relative min-h-screen">
|
||||
{isUpdatingEnvelope && (
|
||||
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center bg-background">
|
||||
<Spinner />
|
||||
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{envelope.type === EnvelopeType.DOCUMENT ? (
|
||||
<Trans>Updating Document</Trans>
|
||||
) : (
|
||||
<Trans>Updating Template</Trans>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{updatedEnvelope && (
|
||||
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center bg-background">
|
||||
<div className="mx-auto w-full max-w-md text-center">
|
||||
<CheckCircle2Icon className="mx-auto h-16 w-16 text-primary" />
|
||||
|
||||
<h1 className="mt-6 text-2xl font-bold">
|
||||
{envelope.type === EnvelopeType.TEMPLATE ? (
|
||||
<Trans>Template Updated</Trans>
|
||||
) : (
|
||||
<Trans>Document Updated</Trans>
|
||||
)}
|
||||
</h1>
|
||||
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
{envelope.type === EnvelopeType.TEMPLATE ? (
|
||||
<Trans>Your template has been updated successfully</Trans>
|
||||
) : (
|
||||
<Trans>Your document has been updated successfully</Trans>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<EnvelopeEditorProvider initialEnvelope={initialEnvelope} editorConfig={editorConfig}>
|
||||
<EnvelopeEditorRenderProviderWrapper presignedToken={token}>
|
||||
<EnvelopeEditor />
|
||||
</EnvelopeEditorRenderProviderWrapper>
|
||||
</EnvelopeEditorProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+180
@@ -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
|
||||
@@ -10,6 +10,12 @@ import { TeamSchema } from '@documenso/prisma/generated/zod/modelSchema/TeamSche
|
||||
import { TemplateDirectLinkSchema } from '@documenso/prisma/generated/zod/modelSchema/TemplateDirectLinkSchema';
|
||||
import { ZBaseEmbedDataSchema } from '@documenso/remix/app/types/embed-base-schemas';
|
||||
|
||||
/**
|
||||
* DO NOT MAKE ANY BREAKING BACKWARD CHANGES HERE UNLESS YOu'RE SURE
|
||||
* IT WON'T BREAK EMBEDDINGS.
|
||||
*
|
||||
* Keep this in sync with the embedded repo (the types + schema)
|
||||
*/
|
||||
export const ZEnvelopeEditorSettingsSchema = z.object({
|
||||
/**
|
||||
* Generic editor related configurations.
|
||||
@@ -92,15 +98,109 @@ export const ZEnvelopeEditorSettingsSchema = z.object({
|
||||
|
||||
export type TEnvelopeEditorSettings = z.infer<typeof ZEnvelopeEditorSettingsSchema>;
|
||||
|
||||
/**
|
||||
* The default editor configuration for normal flows.
|
||||
*/
|
||||
export const DEFAULT_EDITOR_CONFIG: EnvelopeEditorConfig = {
|
||||
general: {
|
||||
allowConfigureEnvelopeTitle: true,
|
||||
allowUploadAndRecipientStep: true,
|
||||
allowAddFieldsStep: true,
|
||||
allowPreviewStep: true,
|
||||
minimizeLeftSidebar: false,
|
||||
},
|
||||
settings: {
|
||||
allowConfigureSignatureTypes: true,
|
||||
allowConfigureLanguage: true,
|
||||
allowConfigureDateFormat: true,
|
||||
allowConfigureTimezone: true,
|
||||
allowConfigureRedirectUrl: true,
|
||||
allowConfigureDistribution: true,
|
||||
},
|
||||
actions: {
|
||||
allowAttachments: true,
|
||||
allowDistributing: true,
|
||||
allowDirectLink: true,
|
||||
allowDuplication: true,
|
||||
allowDownloadPDF: true,
|
||||
allowDeletion: true,
|
||||
allowReturnToPreviousPage: true,
|
||||
},
|
||||
envelopeItems: {
|
||||
allowConfigureTitle: true,
|
||||
allowConfigureOrder: true,
|
||||
allowUpload: true,
|
||||
allowDelete: true,
|
||||
},
|
||||
recipients: {
|
||||
allowAIDetection: true,
|
||||
allowConfigureSigningOrder: true,
|
||||
allowConfigureDictateNextSigner: true,
|
||||
|
||||
allowApproverRole: true,
|
||||
allowViewerRole: true,
|
||||
allowCCerRole: true,
|
||||
allowAssistantRole: true,
|
||||
},
|
||||
fields: {
|
||||
allowAIDetection: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const DEFAULT_EMBEDDED_EDITOR_CONFIG = {
|
||||
general: {
|
||||
allowConfigureEnvelopeTitle: true,
|
||||
allowUploadAndRecipientStep: true,
|
||||
allowAddFieldsStep: true,
|
||||
allowPreviewStep: true,
|
||||
minimizeLeftSidebar: true,
|
||||
},
|
||||
settings: {
|
||||
allowConfigureSignatureTypes: true,
|
||||
allowConfigureLanguage: true,
|
||||
allowConfigureDateFormat: true,
|
||||
allowConfigureTimezone: true,
|
||||
allowConfigureRedirectUrl: true,
|
||||
allowConfigureDistribution: true,
|
||||
},
|
||||
actions: {
|
||||
allowAttachments: true,
|
||||
allowDistributing: false,
|
||||
allowDirectLink: false,
|
||||
allowDuplication: false,
|
||||
allowDownloadPDF: false,
|
||||
allowDeletion: false,
|
||||
allowReturnToPreviousPage: true,
|
||||
},
|
||||
envelopeItems: {
|
||||
allowConfigureTitle: true,
|
||||
allowConfigureOrder: true,
|
||||
allowUpload: true,
|
||||
allowDelete: true,
|
||||
},
|
||||
recipients: {
|
||||
allowAIDetection: true,
|
||||
allowConfigureSigningOrder: true,
|
||||
allowConfigureDictateNextSigner: true,
|
||||
allowApproverRole: true,
|
||||
allowViewerRole: true,
|
||||
allowCCerRole: true,
|
||||
allowAssistantRole: true,
|
||||
},
|
||||
fields: {
|
||||
allowAIDetection: true,
|
||||
},
|
||||
} as const satisfies EnvelopeEditorConfig;
|
||||
|
||||
export const ZEmbedCreateEnvelopeAuthoringSchema = ZBaseEmbedDataSchema.extend({
|
||||
externalId: z.string().optional(),
|
||||
type: z.nativeEnum(EnvelopeType),
|
||||
features: ZEnvelopeEditorSettingsSchema,
|
||||
features: z.object({}).passthrough().optional().default(DEFAULT_EMBEDDED_EDITOR_CONFIG),
|
||||
});
|
||||
|
||||
export const ZEmbedEditEnvelopeAuthoringSchema = ZBaseEmbedDataSchema.extend({
|
||||
externalId: z.string().optional(),
|
||||
features: ZEnvelopeEditorSettingsSchema,
|
||||
features: z.object({}).passthrough().optional().default(DEFAULT_EMBEDDED_EDITOR_CONFIG),
|
||||
});
|
||||
|
||||
export type TEmbedCreateEnvelopeAuthoring = z.infer<typeof ZEmbedCreateEnvelopeAuthoringSchema>;
|
||||
@@ -189,52 +289,3 @@ export type EnvelopeEditorConfig = TEnvelopeEditorSettings & {
|
||||
customBrandingLogo?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* The default editor configuration for normal flows.
|
||||
*/
|
||||
export const DEFAULT_EDITOR_CONFIG: EnvelopeEditorConfig = {
|
||||
general: {
|
||||
allowConfigureEnvelopeTitle: true,
|
||||
allowUploadAndRecipientStep: true,
|
||||
allowAddFieldsStep: true,
|
||||
allowPreviewStep: true,
|
||||
minimizeLeftSidebar: false,
|
||||
},
|
||||
settings: {
|
||||
allowConfigureSignatureTypes: true,
|
||||
allowConfigureLanguage: true,
|
||||
allowConfigureDateFormat: true,
|
||||
allowConfigureTimezone: true,
|
||||
allowConfigureRedirectUrl: true,
|
||||
allowConfigureDistribution: true,
|
||||
},
|
||||
actions: {
|
||||
allowAttachments: true,
|
||||
allowDistributing: true,
|
||||
allowDirectLink: true,
|
||||
allowDuplication: true,
|
||||
allowDownloadPDF: true,
|
||||
allowDeletion: true,
|
||||
allowReturnToPreviousPage: true,
|
||||
},
|
||||
envelopeItems: {
|
||||
allowConfigureTitle: true,
|
||||
allowConfigureOrder: true,
|
||||
allowUpload: true,
|
||||
allowDelete: true,
|
||||
},
|
||||
recipients: {
|
||||
allowAIDetection: true,
|
||||
allowConfigureSigningOrder: true,
|
||||
allowConfigureDictateNextSigner: true,
|
||||
|
||||
allowApproverRole: true,
|
||||
allowViewerRole: true,
|
||||
allowCCerRole: true,
|
||||
allowAssistantRole: true,
|
||||
},
|
||||
fields: {
|
||||
allowAIDetection: true,
|
||||
},
|
||||
};
|
||||
|
||||
+103
-123
@@ -1,60 +1,9 @@
|
||||
import type { EnvelopeEditorConfig, TEnvelopeEditorSettings } from '../types/envelope-editor';
|
||||
import type { EnvelopeEditorConfig } from '../types/envelope-editor';
|
||||
import { DEFAULT_EMBEDDED_EDITOR_CONFIG } from '../types/envelope-editor';
|
||||
|
||||
export const PRESIGNED_ENVELOPE_ITEM_ID_PREFIX = 'PRESIGNED_';
|
||||
|
||||
/**
|
||||
* Embedded-mode defaults for the editor config.
|
||||
*
|
||||
* - `general`: All true, `minimizeLeftSidebar: true`
|
||||
* - `settings`: All true
|
||||
* - `actions`: `allowAttachments: true`; everything else false (parent app controls lifecycle)
|
||||
* - `envelopeItems`: All true
|
||||
* - `recipients`: All true
|
||||
*/
|
||||
const EMBEDDED_DEFAULTS = {
|
||||
general: {
|
||||
allowConfigureEnvelopeTitle: true,
|
||||
allowUploadAndRecipientStep: true,
|
||||
allowAddFieldsStep: true,
|
||||
allowPreviewStep: true,
|
||||
minimizeLeftSidebar: true,
|
||||
},
|
||||
settings: {
|
||||
allowConfigureSignatureTypes: true,
|
||||
allowConfigureLanguage: true,
|
||||
allowConfigureDateFormat: true,
|
||||
allowConfigureTimezone: true,
|
||||
allowConfigureRedirectUrl: true,
|
||||
allowConfigureDistribution: true,
|
||||
},
|
||||
actions: {
|
||||
allowAttachments: true,
|
||||
allowDistributing: false,
|
||||
allowDirectLink: false,
|
||||
allowDuplication: false,
|
||||
allowDownloadPDF: false,
|
||||
allowDeletion: false,
|
||||
allowReturnToPreviousPage: false,
|
||||
},
|
||||
envelopeItems: {
|
||||
allowConfigureTitle: true,
|
||||
allowConfigureOrder: true,
|
||||
allowUpload: true,
|
||||
allowDelete: true,
|
||||
},
|
||||
recipients: {
|
||||
allowAIDetection: true,
|
||||
allowConfigureSigningOrder: true,
|
||||
allowConfigureDictateNextSigner: true,
|
||||
allowApproverRole: true,
|
||||
allowViewerRole: true,
|
||||
allowCCerRole: true,
|
||||
allowAssistantRole: true,
|
||||
},
|
||||
fields: {
|
||||
allowAIDetection: true,
|
||||
},
|
||||
} as const satisfies TEnvelopeEditorSettings;
|
||||
export type DeepPartial<T> = T extends object ? { [K in keyof T]?: DeepPartial<T[K]> } : T;
|
||||
|
||||
/**
|
||||
* Takes parsed `features` from the embedding hash and an `embeded` config,
|
||||
@@ -62,101 +11,132 @@ const EMBEDDED_DEFAULTS = {
|
||||
*
|
||||
* Any explicitly provided feature flag overrides the embedded default.
|
||||
*/
|
||||
export function buildEditorConfigFromFeatures(
|
||||
features: TEnvelopeEditorSettings,
|
||||
export function buildEmbeddedEditorOptions(
|
||||
features: DeepPartial<EnvelopeEditorConfig>,
|
||||
embeded: EnvelopeEditorConfig['embeded'],
|
||||
): EnvelopeEditorConfig {
|
||||
return {
|
||||
embeded,
|
||||
...buildEmbeddedFeatures(features),
|
||||
};
|
||||
}
|
||||
|
||||
export const buildEmbeddedFeatures = (
|
||||
features: DeepPartial<EnvelopeEditorConfig>,
|
||||
): EnvelopeEditorConfig => {
|
||||
return {
|
||||
general: {
|
||||
allowConfigureEnvelopeTitle:
|
||||
features.general.allowConfigureEnvelopeTitle ??
|
||||
EMBEDDED_DEFAULTS.general.allowConfigureEnvelopeTitle,
|
||||
features.general?.allowConfigureEnvelopeTitle ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.general.allowConfigureEnvelopeTitle,
|
||||
allowUploadAndRecipientStep:
|
||||
features.general.allowUploadAndRecipientStep ??
|
||||
EMBEDDED_DEFAULTS.general.allowUploadAndRecipientStep,
|
||||
features.general?.allowUploadAndRecipientStep ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.general.allowUploadAndRecipientStep,
|
||||
allowAddFieldsStep:
|
||||
features.general.allowAddFieldsStep ?? EMBEDDED_DEFAULTS.general.allowAddFieldsStep,
|
||||
features.general?.allowAddFieldsStep ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.general.allowAddFieldsStep,
|
||||
allowPreviewStep:
|
||||
features.general.allowPreviewStep ?? EMBEDDED_DEFAULTS.general.allowPreviewStep,
|
||||
features.general?.allowPreviewStep ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.general.allowPreviewStep,
|
||||
minimizeLeftSidebar:
|
||||
features.general.minimizeLeftSidebar ?? EMBEDDED_DEFAULTS.general.minimizeLeftSidebar,
|
||||
features.general?.minimizeLeftSidebar ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.general.minimizeLeftSidebar,
|
||||
},
|
||||
|
||||
settings: {
|
||||
allowConfigureSignatureTypes:
|
||||
features.settings?.allowConfigureSignatureTypes ??
|
||||
EMBEDDED_DEFAULTS.settings.allowConfigureSignatureTypes,
|
||||
allowConfigureLanguage:
|
||||
features.settings?.allowConfigureLanguage ??
|
||||
EMBEDDED_DEFAULTS.settings.allowConfigureLanguage,
|
||||
allowConfigureDateFormat:
|
||||
features.settings?.allowConfigureDateFormat ??
|
||||
EMBEDDED_DEFAULTS.settings.allowConfigureDateFormat,
|
||||
allowConfigureTimezone:
|
||||
features.settings?.allowConfigureTimezone ??
|
||||
EMBEDDED_DEFAULTS.settings.allowConfigureTimezone,
|
||||
allowConfigureRedirectUrl:
|
||||
features.settings?.allowConfigureRedirectUrl ??
|
||||
EMBEDDED_DEFAULTS.settings.allowConfigureRedirectUrl,
|
||||
allowConfigureDistribution:
|
||||
features.settings?.allowConfigureDistribution ??
|
||||
EMBEDDED_DEFAULTS.settings.allowConfigureDistribution,
|
||||
},
|
||||
settings:
|
||||
features.settings !== null
|
||||
? {
|
||||
allowConfigureSignatureTypes:
|
||||
features.settings?.allowConfigureSignatureTypes ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureSignatureTypes,
|
||||
allowConfigureLanguage:
|
||||
features.settings?.allowConfigureLanguage ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureLanguage,
|
||||
allowConfigureDateFormat:
|
||||
features.settings?.allowConfigureDateFormat ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureDateFormat,
|
||||
allowConfigureTimezone:
|
||||
features.settings?.allowConfigureTimezone ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureTimezone,
|
||||
allowConfigureRedirectUrl:
|
||||
features.settings?.allowConfigureRedirectUrl ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureRedirectUrl,
|
||||
allowConfigureDistribution:
|
||||
features.settings?.allowConfigureDistribution ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureDistribution,
|
||||
}
|
||||
: null,
|
||||
|
||||
actions: {
|
||||
allowAttachments:
|
||||
features.actions.allowAttachments ?? EMBEDDED_DEFAULTS.actions.allowAttachments,
|
||||
features.actions?.allowAttachments ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.actions.allowAttachments,
|
||||
allowDistributing:
|
||||
features.actions.allowDistributing ?? EMBEDDED_DEFAULTS.actions.allowDistributing,
|
||||
features.actions?.allowDistributing ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.actions.allowDistributing,
|
||||
allowDirectLink:
|
||||
features.actions.allowDirectLink ?? EMBEDDED_DEFAULTS.actions.allowDirectLink,
|
||||
features.actions?.allowDirectLink ?? DEFAULT_EMBEDDED_EDITOR_CONFIG.actions.allowDirectLink,
|
||||
allowDuplication:
|
||||
features.actions.allowDuplication ?? EMBEDDED_DEFAULTS.actions.allowDuplication,
|
||||
features.actions?.allowDuplication ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.actions.allowDuplication,
|
||||
allowDownloadPDF:
|
||||
features.actions.allowDownloadPDF ?? EMBEDDED_DEFAULTS.actions.allowDownloadPDF,
|
||||
allowDeletion: features.actions.allowDeletion ?? EMBEDDED_DEFAULTS.actions.allowDeletion,
|
||||
features.actions?.allowDownloadPDF ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.actions.allowDownloadPDF,
|
||||
allowDeletion:
|
||||
features.actions?.allowDeletion ?? DEFAULT_EMBEDDED_EDITOR_CONFIG.actions.allowDeletion,
|
||||
allowReturnToPreviousPage:
|
||||
features.actions.allowReturnToPreviousPage ??
|
||||
EMBEDDED_DEFAULTS.actions.allowReturnToPreviousPage,
|
||||
features.actions?.allowReturnToPreviousPage ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.actions.allowReturnToPreviousPage,
|
||||
},
|
||||
|
||||
envelopeItems: {
|
||||
allowConfigureTitle:
|
||||
features.envelopeItems?.allowConfigureTitle ??
|
||||
EMBEDDED_DEFAULTS.envelopeItems.allowConfigureTitle,
|
||||
allowConfigureOrder:
|
||||
features.envelopeItems?.allowConfigureOrder ??
|
||||
EMBEDDED_DEFAULTS.envelopeItems.allowConfigureOrder,
|
||||
allowUpload:
|
||||
features.envelopeItems?.allowUpload ?? EMBEDDED_DEFAULTS.envelopeItems.allowUpload,
|
||||
allowDelete:
|
||||
features.envelopeItems?.allowDelete ?? EMBEDDED_DEFAULTS.envelopeItems.allowDelete,
|
||||
},
|
||||
envelopeItems:
|
||||
features.envelopeItems !== null
|
||||
? {
|
||||
allowConfigureTitle:
|
||||
features.envelopeItems?.allowConfigureTitle ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.envelopeItems.allowConfigureTitle,
|
||||
allowConfigureOrder:
|
||||
features.envelopeItems?.allowConfigureOrder ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.envelopeItems.allowConfigureOrder,
|
||||
allowUpload:
|
||||
features.envelopeItems?.allowUpload ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.envelopeItems.allowUpload,
|
||||
allowDelete:
|
||||
features.envelopeItems?.allowDelete ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.envelopeItems.allowDelete,
|
||||
}
|
||||
: null,
|
||||
|
||||
recipients: {
|
||||
allowAIDetection:
|
||||
features.recipients?.allowAIDetection ?? EMBEDDED_DEFAULTS.recipients.allowAIDetection,
|
||||
allowConfigureSigningOrder:
|
||||
features.recipients?.allowConfigureSigningOrder ??
|
||||
EMBEDDED_DEFAULTS.recipients.allowConfigureSigningOrder,
|
||||
allowConfigureDictateNextSigner:
|
||||
features.recipients?.allowConfigureDictateNextSigner ??
|
||||
EMBEDDED_DEFAULTS.recipients.allowConfigureDictateNextSigner,
|
||||
allowApproverRole:
|
||||
features.recipients?.allowApproverRole ?? EMBEDDED_DEFAULTS.recipients.allowApproverRole,
|
||||
allowViewerRole:
|
||||
features.recipients?.allowViewerRole ?? EMBEDDED_DEFAULTS.recipients.allowViewerRole,
|
||||
allowCCerRole:
|
||||
features.recipients?.allowCCerRole ?? EMBEDDED_DEFAULTS.recipients.allowCCerRole,
|
||||
allowAssistantRole:
|
||||
features.recipients?.allowAssistantRole ?? EMBEDDED_DEFAULTS.recipients.allowAssistantRole,
|
||||
},
|
||||
recipients:
|
||||
features.recipients !== null
|
||||
? {
|
||||
allowAIDetection:
|
||||
features.recipients?.allowAIDetection ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.recipients.allowAIDetection,
|
||||
allowConfigureSigningOrder:
|
||||
features.recipients?.allowConfigureSigningOrder ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.recipients.allowConfigureSigningOrder,
|
||||
allowConfigureDictateNextSigner:
|
||||
features.recipients?.allowConfigureDictateNextSigner ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.recipients.allowConfigureDictateNextSigner,
|
||||
allowApproverRole:
|
||||
features.recipients?.allowApproverRole ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.recipients.allowApproverRole,
|
||||
allowViewerRole:
|
||||
features.recipients?.allowViewerRole ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.recipients.allowViewerRole,
|
||||
allowCCerRole:
|
||||
features.recipients?.allowCCerRole ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.recipients.allowCCerRole,
|
||||
allowAssistantRole:
|
||||
features.recipients?.allowAssistantRole ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.recipients.allowAssistantRole,
|
||||
}
|
||||
: null,
|
||||
|
||||
fields: {
|
||||
allowAIDetection:
|
||||
features.fields?.allowAIDetection ?? EMBEDDED_DEFAULTS.fields.allowAIDetection,
|
||||
features.fields?.allowAIDetection ?? DEFAULT_EMBEDDED_EDITOR_CONFIG.fields.allowAIDetection,
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
import { DocumentStatus, EnvelopeType } from '@prisma/client';
|
||||
import pMap from 'p-map';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
|
||||
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
||||
import { updateEnvelope } from '@documenso/lib/server-only/envelope/update-envelope';
|
||||
import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document';
|
||||
import { setFieldsForTemplate } from '@documenso/lib/server-only/field/set-fields-for-template';
|
||||
import { setDocumentRecipients } from '@documenso/lib/server-only/recipient/set-document-recipients';
|
||||
import { setTemplateRecipients } from '@documenso/lib/server-only/recipient/set-template-recipients';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { PRESIGNED_ENVELOPE_ITEM_ID_PREFIX } from '@documenso/lib/utils/embed-config';
|
||||
import { canEnvelopeItemsBeModified } from '@documenso/lib/utils/envelope';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { UNSAFE_createEnvelopeItems } from '@documenso/trpc/server/envelope-router/create-envelope-items';
|
||||
import { UNSAFE_deleteEnvelopeItem } from '@documenso/trpc/server/envelope-router/delete-envelope-item';
|
||||
import { UNSAFE_updateEnvelopeItems } from '@documenso/trpc/server/envelope-router/update-envelope-items';
|
||||
|
||||
import { procedure } from '../trpc';
|
||||
import {
|
||||
ZUpdateEmbeddingEnvelopeRequestSchema,
|
||||
ZUpdateEmbeddingEnvelopeResponseSchema,
|
||||
} from './update-embedding-envelope.types';
|
||||
|
||||
export const updateEmbeddingEnvelopeRoute = procedure
|
||||
.input(ZUpdateEmbeddingEnvelopeRequestSchema)
|
||||
.output(ZUpdateEmbeddingEnvelopeResponseSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { payload, files } = input;
|
||||
const { envelopeId, data, meta } = payload;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
envelopeId,
|
||||
},
|
||||
});
|
||||
|
||||
const authorizationHeader = ctx.req.headers.get('authorization');
|
||||
|
||||
const [presignToken] = (authorizationHeader || '').split('Bearer ').filter((s) => s.length > 0);
|
||||
|
||||
if (!presignToken) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'No presign token provided',
|
||||
});
|
||||
}
|
||||
|
||||
const apiToken = await verifyEmbeddingPresignToken({
|
||||
token: presignToken,
|
||||
scope: `envelopeId:${envelopeId}`,
|
||||
});
|
||||
|
||||
const { envelopeWhereInput } = await getEnvelopeWhereInput({
|
||||
id: {
|
||||
type: 'envelopeId',
|
||||
id: envelopeId,
|
||||
},
|
||||
type: null, // Allow updating both documents and templates.
|
||||
userId: apiToken.userId,
|
||||
teamId: apiToken.teamId,
|
||||
});
|
||||
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: envelopeWhereInput,
|
||||
include: {
|
||||
envelopeItems: true,
|
||||
team: {
|
||||
select: {
|
||||
organisation: {
|
||||
select: {
|
||||
organisationClaim: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
recipients: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!envelope) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Envelope not found',
|
||||
});
|
||||
}
|
||||
|
||||
if (envelope.status === DocumentStatus.COMPLETED) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Cannot modify completed envelope',
|
||||
});
|
||||
}
|
||||
|
||||
// Step 1: Update the envelope items.
|
||||
const envelopeItemsToUpdate: EnvelopeItemUpdateOptions[] = [];
|
||||
const envelopeItemsToCreate: EnvelopeItemCreateOptions[] = [];
|
||||
|
||||
// Sort and group envelope items to update and create.
|
||||
data.envelopeItems.forEach((item) => {
|
||||
const isNewEnvelopeItem = item.id.startsWith(PRESIGNED_ENVELOPE_ITEM_ID_PREFIX);
|
||||
|
||||
// Handle existing envelope items.
|
||||
if (!isNewEnvelopeItem) {
|
||||
const envelopeItem = envelope.envelopeItems.find(
|
||||
(envelopeItem) => envelopeItem.id === item.id,
|
||||
);
|
||||
|
||||
if (!envelopeItem) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Envelope item not found',
|
||||
});
|
||||
}
|
||||
|
||||
const hasEnvelopeItemChanged =
|
||||
envelopeItem.title !== item.title || envelopeItem.order !== item.order;
|
||||
|
||||
if (hasEnvelopeItemChanged) {
|
||||
envelopeItemsToUpdate.push({
|
||||
envelopeItemId: envelopeItem.id,
|
||||
title: item.title,
|
||||
order: item.order,
|
||||
});
|
||||
}
|
||||
|
||||
// Return to continue loop.
|
||||
return;
|
||||
}
|
||||
|
||||
const newEnvelopeItemFile = item.index !== undefined ? files[item.index] : undefined;
|
||||
|
||||
if (!newEnvelopeItemFile) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: 'Invalid envelope item index',
|
||||
});
|
||||
}
|
||||
|
||||
// Handle not yet uploaded envelope items.
|
||||
envelopeItemsToCreate.push({
|
||||
embeddedEnvelopeItemId: item.id,
|
||||
title: item.title,
|
||||
order: item.order,
|
||||
file: newEnvelopeItemFile,
|
||||
});
|
||||
});
|
||||
|
||||
// Delete envelope items that have been removed from the payload.
|
||||
const envelopeItemIdsToDelete = envelope.envelopeItems
|
||||
.filter((item) => !data.envelopeItems.some((i) => i.id === item.id))
|
||||
.map((item) => item.id);
|
||||
|
||||
const willEnvelopeItemsBeModified =
|
||||
envelopeItemIdsToDelete.length > 0 ||
|
||||
envelopeItemsToCreate.length > 0 ||
|
||||
envelopeItemsToUpdate.length > 0;
|
||||
|
||||
const organisationClaim = envelope.team.organisation.organisationClaim;
|
||||
const resultingEnvelopeItemCount =
|
||||
envelope.envelopeItems.length - envelopeItemIdsToDelete.length + envelopeItemsToCreate.length;
|
||||
|
||||
if (resultingEnvelopeItemCount > organisationClaim.envelopeItemCount) {
|
||||
throw new AppError('ENVELOPE_ITEM_LIMIT_EXCEEDED', {
|
||||
message: `You cannot upload more than ${organisationClaim.envelopeItemCount} envelope items`,
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
// Should be safe to use stale envelope.recipients since only signed or sent
|
||||
// recipients affect the outcome.
|
||||
if (willEnvelopeItemsBeModified && !canEnvelopeItemsBeModified(envelope, envelope.recipients)) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Envelope item is not editable',
|
||||
});
|
||||
}
|
||||
|
||||
if (envelopeItemIdsToDelete.length > 0) {
|
||||
console.log('[DEBUG]: Deleting envelope items', envelopeItemIdsToDelete);
|
||||
|
||||
await pMap(
|
||||
envelopeItemIdsToDelete,
|
||||
async (envelopeItemId) => {
|
||||
await UNSAFE_deleteEnvelopeItem({
|
||||
envelopeId: envelope.id,
|
||||
envelopeItemId,
|
||||
user: apiToken.user,
|
||||
apiRequestMetadata: ctx.metadata,
|
||||
});
|
||||
},
|
||||
{ concurrency: 2 },
|
||||
);
|
||||
}
|
||||
|
||||
// Mapping for the client side embedded prefix envelope item IDs to the real envelope item IDs.
|
||||
const embeddedEnvelopeItemIdMapping: Record<string, string> = {};
|
||||
|
||||
// Create new envelope items.
|
||||
if (envelopeItemsToCreate.length > 0) {
|
||||
console.log('[DEBUG]: Creating envelope items', envelopeItemsToCreate);
|
||||
|
||||
const createdEnvelopeItems = await UNSAFE_createEnvelopeItems({
|
||||
files: envelopeItemsToCreate.map((item) => ({
|
||||
clientId: item.embeddedEnvelopeItemId,
|
||||
file: item.file,
|
||||
orderOverride: item.order,
|
||||
})),
|
||||
envelope: {
|
||||
...envelope,
|
||||
// Purposefully putting empty recipients here since placeholders should automatically injected on the client side for
|
||||
// embeded purposes. Todo: Embeds - (Not implemeneted yet)
|
||||
recipients: [],
|
||||
},
|
||||
user: {
|
||||
id: apiToken.user.id,
|
||||
name: apiToken.user.name,
|
||||
email: apiToken.user.email,
|
||||
},
|
||||
apiRequestMetadata: ctx.metadata,
|
||||
});
|
||||
|
||||
// Build the map from the envelope item order.
|
||||
createdEnvelopeItems.forEach((item) => {
|
||||
if (!item.clientId) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Client ID not found',
|
||||
});
|
||||
}
|
||||
|
||||
embeddedEnvelopeItemIdMapping[item.clientId] = item.id;
|
||||
});
|
||||
}
|
||||
|
||||
if (envelopeItemsToUpdate.length > 0) {
|
||||
console.log('[DEBUG]: Updating envelope items', envelopeItemsToUpdate);
|
||||
|
||||
await UNSAFE_updateEnvelopeItems({
|
||||
envelopeId: envelope.id,
|
||||
data: envelopeItemsToUpdate,
|
||||
});
|
||||
}
|
||||
|
||||
// Step 2: Update the general envelope data and meta.
|
||||
await updateEnvelope({
|
||||
userId: apiToken.userId,
|
||||
teamId: apiToken.teamId,
|
||||
id: {
|
||||
type: 'envelopeId',
|
||||
id: envelope.id,
|
||||
},
|
||||
data: {
|
||||
title: data.title,
|
||||
externalId: data.externalId,
|
||||
visibility: data.visibility,
|
||||
globalAccessAuth: data.globalAccessAuth,
|
||||
globalActionAuth: data.globalActionAuth,
|
||||
folderId: data.folderId,
|
||||
},
|
||||
meta,
|
||||
requestMetadata: ctx.metadata,
|
||||
});
|
||||
|
||||
// Step 3: Update the recipients
|
||||
const recipientsWithClientId = data.recipients.map((recipient) => ({
|
||||
...recipient,
|
||||
clientId: nanoid(),
|
||||
}));
|
||||
|
||||
const { recipients: updatedRecipients } = await match(envelope.type)
|
||||
.with(EnvelopeType.DOCUMENT, async () =>
|
||||
setDocumentRecipients({
|
||||
userId: apiToken.userId,
|
||||
teamId: apiToken.teamId,
|
||||
id: {
|
||||
type: 'envelopeId',
|
||||
id: envelope.id,
|
||||
},
|
||||
recipients: recipientsWithClientId.map((recipient) => ({
|
||||
id: recipient.id,
|
||||
clientId: recipient.clientId,
|
||||
email: recipient.email,
|
||||
name: recipient.name ?? '',
|
||||
role: recipient.role,
|
||||
signingOrder: recipient.signingOrder,
|
||||
})),
|
||||
requestMetadata: ctx.metadata,
|
||||
}),
|
||||
)
|
||||
.with(EnvelopeType.TEMPLATE, async () =>
|
||||
setTemplateRecipients({
|
||||
userId: apiToken.userId,
|
||||
teamId: apiToken.teamId,
|
||||
id: {
|
||||
type: 'envelopeId',
|
||||
id: envelope.id,
|
||||
},
|
||||
recipients: recipientsWithClientId.map((recipient) => ({
|
||||
id: recipient.id,
|
||||
clientId: recipient.clientId,
|
||||
email: recipient.email,
|
||||
name: recipient.name ?? '',
|
||||
role: recipient.role,
|
||||
signingOrder: recipient.signingOrder,
|
||||
})),
|
||||
}),
|
||||
)
|
||||
.exhaustive();
|
||||
|
||||
// Step 4: Update the fields.
|
||||
const fields = recipientsWithClientId.flatMap((recipient) => {
|
||||
const recipientId = updatedRecipients.find((r) => r.clientId === recipient.clientId)?.id;
|
||||
|
||||
if (!recipientId) {
|
||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
||||
message: 'Recipient not found',
|
||||
});
|
||||
}
|
||||
|
||||
return (recipient.fields ?? []).map((field) => {
|
||||
let envelopeItemId = field.envelopeItemId;
|
||||
|
||||
if (envelopeItemId.startsWith(PRESIGNED_ENVELOPE_ITEM_ID_PREFIX)) {
|
||||
envelopeItemId = embeddedEnvelopeItemIdMapping[envelopeItemId];
|
||||
}
|
||||
|
||||
if (!envelopeItemId) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Envelope item not found',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...field,
|
||||
recipientId,
|
||||
envelopeItemId,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
await match(envelope.type)
|
||||
.with(EnvelopeType.DOCUMENT, async () =>
|
||||
setFieldsForDocument({
|
||||
userId: apiToken.userId,
|
||||
teamId: apiToken.teamId,
|
||||
id: {
|
||||
type: 'envelopeId',
|
||||
id: envelopeId,
|
||||
},
|
||||
fields: fields.map((field) => ({
|
||||
...field,
|
||||
pageNumber: field.page,
|
||||
pageX: field.positionX,
|
||||
pageY: field.positionY,
|
||||
pageWidth: field.width,
|
||||
pageHeight: field.height,
|
||||
})),
|
||||
requestMetadata: ctx.metadata,
|
||||
}),
|
||||
)
|
||||
.with(EnvelopeType.TEMPLATE, async () =>
|
||||
setFieldsForTemplate({
|
||||
userId: apiToken.userId,
|
||||
teamId: apiToken.teamId,
|
||||
id: {
|
||||
type: 'envelopeId',
|
||||
id: envelopeId,
|
||||
},
|
||||
fields: fields.map((field) => ({
|
||||
...field,
|
||||
pageNumber: field.page,
|
||||
pageX: field.positionX,
|
||||
pageY: field.positionY,
|
||||
pageWidth: field.width,
|
||||
pageHeight: field.height,
|
||||
})),
|
||||
}),
|
||||
)
|
||||
.exhaustive();
|
||||
});
|
||||
|
||||
type EnvelopeItemUpdateOptions = {
|
||||
envelopeItemId: string;
|
||||
title?: string;
|
||||
order?: number;
|
||||
};
|
||||
|
||||
type EnvelopeItemCreateOptions = {
|
||||
embeddedEnvelopeItemId: string;
|
||||
title: string;
|
||||
order: number;
|
||||
file: File;
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
import { z } from 'zod';
|
||||
import { zfd } from 'zod-form-data';
|
||||
|
||||
import {
|
||||
ZDocumentAccessAuthTypesSchema,
|
||||
ZDocumentActionAuthTypesSchema,
|
||||
} from '@documenso/lib/types/document-auth';
|
||||
import { ZDocumentMetaUpdateSchema } from '@documenso/lib/types/document-meta';
|
||||
import {
|
||||
ZClampedFieldHeightSchema,
|
||||
ZClampedFieldPositionXSchema,
|
||||
ZClampedFieldPositionYSchema,
|
||||
ZClampedFieldWidthSchema,
|
||||
ZFieldPageNumberSchema,
|
||||
} from '@documenso/lib/types/field';
|
||||
import { ZEnvelopeFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
|
||||
import { ZSetEnvelopeRecipientSchema } from '@documenso/trpc/server/envelope-router/set-envelope-recipients.types';
|
||||
|
||||
import { zodFormData } from '../../utils/zod-form-data';
|
||||
import {
|
||||
ZDocumentExternalIdSchema,
|
||||
ZDocumentTitleSchema,
|
||||
ZDocumentVisibilitySchema,
|
||||
} from '../document-router/schema';
|
||||
|
||||
export const ZUpdateEmbeddingEnvelopePayloadSchema = z.object({
|
||||
envelopeId: z.string(),
|
||||
data: z.object({
|
||||
title: ZDocumentTitleSchema.optional(),
|
||||
externalId: ZDocumentExternalIdSchema.nullish(),
|
||||
visibility: ZDocumentVisibilitySchema.optional(),
|
||||
globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema).optional(),
|
||||
globalActionAuth: z.array(ZDocumentActionAuthTypesSchema).optional(),
|
||||
folderId: z.string().nullish(),
|
||||
|
||||
/**
|
||||
* The list of envelope items that are part of the envelope.
|
||||
*
|
||||
* Any missing IDs will be treated as deleting the envelope item.
|
||||
*/
|
||||
envelopeItems: z
|
||||
.object({
|
||||
/**
|
||||
* This is not necesssarily a real id, it can be a temporary id for the envelope item.
|
||||
*/
|
||||
id: z.string(),
|
||||
|
||||
/**
|
||||
* The title of the envelope item.
|
||||
*/
|
||||
title: z.string(),
|
||||
|
||||
/**
|
||||
* The order of the envelope item in the envelope.
|
||||
*/
|
||||
order: z.number().int().min(0),
|
||||
|
||||
/**
|
||||
* The file index for items that are not yet uploaded.
|
||||
*/
|
||||
index: z.number().int().min(0).optional(),
|
||||
})
|
||||
.array(),
|
||||
|
||||
/**
|
||||
* This is a set command.
|
||||
*/
|
||||
recipients: ZSetEnvelopeRecipientSchema.extend({
|
||||
fields: ZEnvelopeFieldAndMetaSchema.and(
|
||||
z.object({
|
||||
id: z.number().optional(),
|
||||
page: ZFieldPageNumberSchema,
|
||||
positionX: ZClampedFieldPositionXSchema,
|
||||
positionY: ZClampedFieldPositionYSchema,
|
||||
width: ZClampedFieldWidthSchema,
|
||||
height: ZClampedFieldHeightSchema,
|
||||
envelopeItemId: z.string(),
|
||||
}),
|
||||
).array(),
|
||||
}).array(),
|
||||
}),
|
||||
|
||||
meta: ZDocumentMetaUpdateSchema.optional(),
|
||||
});
|
||||
|
||||
export const ZUpdateEmbeddingEnvelopeRequestSchema = zodFormData({
|
||||
payload: zfd.json(ZUpdateEmbeddingEnvelopePayloadSchema),
|
||||
files: zfd.repeatableOfType(zfd.file()),
|
||||
});
|
||||
|
||||
export const ZUpdateEmbeddingEnvelopeResponseSchema = z.void();
|
||||
|
||||
export type TUpdateEmbeddingEnvelopePayload = z.infer<typeof ZUpdateEmbeddingEnvelopePayloadSchema>;
|
||||
export type TUpdateEmbeddingEnvelopeRequest = z.infer<typeof ZUpdateEmbeddingEnvelopeRequestSchema>;
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { Envelope, EnvelopeItem, Recipient } from '@prisma/client';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
||||
import {
|
||||
@@ -8,6 +10,7 @@ import { findRecipientByPlaceholder } from '@documenso/lib/server-only/pdf/helpe
|
||||
import { insertFormValuesInPdf } from '@documenso/lib/server-only/pdf/insert-form-values-in-pdf';
|
||||
import { normalizePdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { prefixedId } from '@documenso/lib/universal/id';
|
||||
import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
@@ -91,133 +94,186 @@ export const createEnvelopeItemsRoute = authenticatedProcedure
|
||||
});
|
||||
}
|
||||
|
||||
// For each file: normalize, extract & clean placeholders, then upload.
|
||||
const envelopeItems = await Promise.all(
|
||||
files.map(async (file) => {
|
||||
let buffer = Buffer.from(await file.arrayBuffer());
|
||||
|
||||
if (envelope.formValues) {
|
||||
buffer = await insertFormValuesInPdf({ pdf: buffer, formValues: envelope.formValues });
|
||||
}
|
||||
|
||||
const normalized = await normalizePdf(buffer, {
|
||||
flattenForm: envelope.type !== 'TEMPLATE',
|
||||
});
|
||||
|
||||
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized);
|
||||
|
||||
const { id: documentDataId } = await putPdfFileServerSide({
|
||||
name: file.name,
|
||||
type: 'application/pdf',
|
||||
arrayBuffer: async () => Promise.resolve(cleanedPdf),
|
||||
});
|
||||
|
||||
return {
|
||||
title: file.name,
|
||||
documentDataId,
|
||||
placeholders,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const currentHighestOrderValue =
|
||||
envelope.envelopeItems[envelope.envelopeItems.length - 1]?.order ?? 1;
|
||||
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const createdItems = await tx.envelopeItem.createManyAndReturn({
|
||||
data: envelopeItems.map((item) => ({
|
||||
id: prefixedId('envelope_item'),
|
||||
envelopeId,
|
||||
title: item.title,
|
||||
documentDataId: item.documentDataId,
|
||||
order: currentHighestOrderValue + 1,
|
||||
})),
|
||||
include: {
|
||||
documentData: true,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.documentAuditLog.createMany({
|
||||
data: createdItems.map((item) =>
|
||||
createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.ENVELOPE_ITEM_CREATED,
|
||||
envelopeId: envelope.id,
|
||||
data: {
|
||||
envelopeItemId: item.id,
|
||||
envelopeItemTitle: item.title,
|
||||
},
|
||||
user: {
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
},
|
||||
requestMetadata: metadata.requestMetadata,
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
// Create fields from placeholders if the envelope already has recipients.
|
||||
if (envelope.recipients.length > 0) {
|
||||
const orderedRecipients = [...envelope.recipients].sort((a, b) => {
|
||||
const aOrder = a.signingOrder ?? Number.MAX_SAFE_INTEGER;
|
||||
const bOrder = b.signingOrder ?? Number.MAX_SAFE_INTEGER;
|
||||
|
||||
if (aOrder !== bOrder) {
|
||||
return aOrder - bOrder;
|
||||
}
|
||||
|
||||
return a.id - b.id;
|
||||
});
|
||||
|
||||
for (const uploadedItem of envelopeItems) {
|
||||
if (!uploadedItem.placeholders || uploadedItem.placeholders.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const createdItem = createdItems.find(
|
||||
(ci) => ci.documentDataId === uploadedItem.documentDataId,
|
||||
);
|
||||
|
||||
if (!createdItem) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fieldsToCreate = convertPlaceholdersToFieldInputs(
|
||||
uploadedItem.placeholders,
|
||||
(recipientPlaceholder, placeholder) =>
|
||||
findRecipientByPlaceholder(
|
||||
recipientPlaceholder,
|
||||
placeholder,
|
||||
orderedRecipients,
|
||||
orderedRecipients,
|
||||
),
|
||||
createdItem.id,
|
||||
);
|
||||
|
||||
if (fieldsToCreate.length > 0) {
|
||||
await tx.field.createMany({
|
||||
data: fieldsToCreate.map((field) => ({
|
||||
envelopeId: envelope.id,
|
||||
envelopeItemId: createdItem.id,
|
||||
recipientId: field.recipientId,
|
||||
type: field.type,
|
||||
page: field.page,
|
||||
positionX: field.positionX,
|
||||
positionY: field.positionY,
|
||||
width: field.width,
|
||||
height: field.height,
|
||||
customText: '',
|
||||
inserted: false,
|
||||
fieldMeta: field.fieldMeta || undefined,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return createdItems;
|
||||
const result = await UNSAFE_createEnvelopeItems({
|
||||
files: files.map((file) => ({
|
||||
file,
|
||||
})),
|
||||
envelope,
|
||||
user: {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
},
|
||||
apiRequestMetadata: metadata,
|
||||
});
|
||||
|
||||
return {
|
||||
data: result,
|
||||
};
|
||||
});
|
||||
|
||||
type UnsafeCreateEnvelopeItemsOptions = {
|
||||
files: {
|
||||
clientId?: string;
|
||||
file: File;
|
||||
orderOverride?: number;
|
||||
}[];
|
||||
envelope: Envelope & {
|
||||
envelopeItems: EnvelopeItem[];
|
||||
recipients: Recipient[];
|
||||
};
|
||||
user: {
|
||||
id: number;
|
||||
name: string | null;
|
||||
email: string;
|
||||
};
|
||||
apiRequestMetadata: ApiRequestMetadata;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create envelope items.
|
||||
*
|
||||
* It is assumed all prior validation has been completed.
|
||||
*/
|
||||
export const UNSAFE_createEnvelopeItems = async ({
|
||||
files,
|
||||
envelope,
|
||||
user,
|
||||
apiRequestMetadata,
|
||||
}: UnsafeCreateEnvelopeItemsOptions) => {
|
||||
const currentHighestOrderValue =
|
||||
envelope.envelopeItems[envelope.envelopeItems.length - 1]?.order ?? 1;
|
||||
|
||||
// For each file: normalize, extract & clean placeholders, then upload.
|
||||
const envelopeItemsToCreate = await Promise.all(
|
||||
files.map(async ({ file, orderOverride, clientId }, index) => {
|
||||
let buffer = Buffer.from(await file.arrayBuffer());
|
||||
|
||||
if (envelope.formValues) {
|
||||
buffer = await insertFormValuesInPdf({ pdf: buffer, formValues: envelope.formValues });
|
||||
}
|
||||
|
||||
const normalized = await normalizePdf(buffer, {
|
||||
flattenForm: envelope.type !== 'TEMPLATE',
|
||||
});
|
||||
|
||||
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized);
|
||||
|
||||
const { id: documentDataId } = await putPdfFileServerSide({
|
||||
name: file.name,
|
||||
type: 'application/pdf',
|
||||
arrayBuffer: async () => Promise.resolve(cleanedPdf),
|
||||
});
|
||||
|
||||
return {
|
||||
id: prefixedId('envelope_item'),
|
||||
title: file.name,
|
||||
clientId,
|
||||
documentDataId,
|
||||
placeholders,
|
||||
order: orderOverride ?? currentHighestOrderValue + index + 1,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const createdItems = await tx.envelopeItem.createManyAndReturn({
|
||||
data: envelopeItemsToCreate.map((item) => ({
|
||||
id: item.id,
|
||||
envelopeId: envelope.id,
|
||||
title: item.title,
|
||||
documentDataId: item.documentDataId,
|
||||
order: item.order,
|
||||
})),
|
||||
include: {
|
||||
documentData: true,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.documentAuditLog.createMany({
|
||||
data: createdItems.map((item) =>
|
||||
createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.ENVELOPE_ITEM_CREATED,
|
||||
envelopeId: envelope.id,
|
||||
data: {
|
||||
envelopeItemId: item.id,
|
||||
envelopeItemTitle: item.title,
|
||||
},
|
||||
user: {
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
},
|
||||
requestMetadata: apiRequestMetadata.requestMetadata,
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
// Create fields from placeholders if the envelope already has recipients.
|
||||
if (envelope.recipients.length > 0) {
|
||||
const orderedRecipients = [...envelope.recipients].sort((a, b) => {
|
||||
const aOrder = a.signingOrder ?? Number.MAX_SAFE_INTEGER;
|
||||
const bOrder = b.signingOrder ?? Number.MAX_SAFE_INTEGER;
|
||||
|
||||
if (aOrder !== bOrder) {
|
||||
return aOrder - bOrder;
|
||||
}
|
||||
|
||||
return a.id - b.id;
|
||||
});
|
||||
|
||||
for (const uploadedItem of envelopeItemsToCreate) {
|
||||
if (!uploadedItem.placeholders || uploadedItem.placeholders.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const createdItem = createdItems.find(
|
||||
(ci) => ci.documentDataId === uploadedItem.documentDataId,
|
||||
);
|
||||
|
||||
if (!createdItem) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fieldsToCreate = convertPlaceholdersToFieldInputs(
|
||||
uploadedItem.placeholders,
|
||||
(recipientPlaceholder, placeholder) =>
|
||||
findRecipientByPlaceholder(
|
||||
recipientPlaceholder,
|
||||
placeholder,
|
||||
orderedRecipients,
|
||||
orderedRecipients,
|
||||
),
|
||||
createdItem.id,
|
||||
);
|
||||
|
||||
if (fieldsToCreate.length > 0) {
|
||||
await tx.field.createMany({
|
||||
data: fieldsToCreate.map((field) => ({
|
||||
envelopeId: envelope.id,
|
||||
envelopeItemId: createdItem.id,
|
||||
recipientId: field.recipientId,
|
||||
type: field.type,
|
||||
page: field.page,
|
||||
positionX: field.positionX,
|
||||
positionY: field.positionY,
|
||||
width: field.width,
|
||||
height: field.height,
|
||||
customText: '',
|
||||
inserted: false,
|
||||
fieldMeta: field.fieldMeta || undefined,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return createdItems.map((item) => {
|
||||
const clientId = envelopeItemsToCreate.find((file) => file.id === item.id)?.clientId;
|
||||
|
||||
return {
|
||||
...item,
|
||||
clientId,
|
||||
};
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
+300
@@ -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 = <T extends ZodRawShape>(schema: T) => {
|
||||
return z.preprocess((data) => {
|
||||
if (data instanceof FormData) {
|
||||
const formData: Record<string, unknown> = {};
|
||||
|
||||
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<string, unknown> = {};
|
||||
|
||||
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<string | number | symbol, unknown>)[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.
|
||||
Reference in New Issue
Block a user