feat: add envelopes (#2025)

This PR is handles the changes required to support envelopes. The new
envelope editor/signing page will be hidden during release.

The core changes here is to migrate the documents and templates model to
a centralized envelopes model.

Even though Documents and Templates are removed, from the user
perspective they will still exist as we remap envelopes to documents and
templates.
This commit is contained in:
David Nguyen
2025-10-14 21:56:36 +11:00
committed by GitHub
parent 7b17156e56
commit 7f09ba72f4
447 changed files with 33467 additions and 9622 deletions

View File

@ -1,5 +1,5 @@
import { trpc } from '@documenso/trpc/react';
import type { TCreateOrGetShareLinkMutationSchema } from '@documenso/trpc/server/share-link-router/schema';
import type { TShareDocumentRequest } from '@documenso/trpc/server/document-router/share-document.types';
import { useCopyToClipboard } from './use-copy-to-clipboard';
@ -12,14 +12,14 @@ export function useCopyShareLink({ onSuccess, onError }: UseCopyShareLinkOptions
const [, copyToClipboard] = useCopyToClipboard();
const { mutateAsync: createOrGetShareLink, isPending: isCreatingShareLink } =
trpc.shareLink.createOrGetShareLink.useMutation();
trpc.document.share.useMutation();
/**
* Copy a newly created, or pre-existing share link to the user's clipboard.
*
* @param payload The payload to create or get a share link.
*/
const createAndCopyShareLink = async (payload: TCreateOrGetShareLinkMutationSchema) => {
const createAndCopyShareLink = async (payload: TShareDocumentRequest) => {
const valueToCopy = createOrGetShareLink(payload).then(
(result) => `${window.location.origin}/share/${result.slug}`,
);

View File

@ -0,0 +1,281 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import type { Recipient } from '@prisma/client';
import { FieldType } from '@prisma/client';
import { useFieldArray, useForm } from 'react-hook-form';
import { z } from 'zod';
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
import { nanoid } from '@documenso/lib/universal/id';
import type { TEnvelope } from '../../types/envelope';
export const ZLocalFieldSchema = z.object({
// This is the actual ID of the field if created.
id: z.number().optional(),
// This is the local client side ID of the field.
formId: z.string().min(1),
// This is the ID of the envelope item to put the field on.
envelopeItemId: z.string(),
type: z.nativeEnum(FieldType),
recipientId: z.number(),
page: z.number().min(1),
positionX: z.number().min(0),
positionY: z.number().min(0),
width: z.number().min(0),
height: z.number().min(0),
fieldMeta: ZFieldMetaSchema,
});
export type TLocalField = z.infer<typeof ZLocalFieldSchema>;
const ZEditorFieldsFormSchema = z.object({
fields: z.array(ZLocalFieldSchema),
});
export type TEditorFieldsFormSchema = z.infer<typeof ZEditorFieldsFormSchema>;
type EditorFieldsProps = {
envelope: TEnvelope;
handleFieldsUpdate: (fields: TLocalField[]) => unknown;
};
type UseEditorFieldsResponse = {
localFields: TLocalField[];
// Selected field
selectedField: TLocalField | undefined;
setSelectedField: (formId: string | null) => void;
// Field operations
addField: (field: Omit<TLocalField, 'formId'>) => TLocalField;
removeFieldsByFormId: (formIds: string[]) => void;
updateFieldByFormId: (formId: string, updates: Partial<TLocalField>) => void;
duplicateField: (field: TLocalField, recipientId?: number) => TLocalField;
duplicateFieldToAllPages: (field: TLocalField, recipientId?: number) => TLocalField[];
// Field utilities
getFieldByFormId: (formId: string) => TLocalField | undefined;
getFieldsByRecipient: (recipientId: number) => TLocalField[];
// Selected recipient
selectedRecipient: Recipient | null;
setSelectedRecipient: (recipientId: number | null) => void;
};
export const useEditorFields = ({
envelope,
handleFieldsUpdate,
}: EditorFieldsProps): UseEditorFieldsResponse => {
const [selectedFieldFormId, setSelectedFieldFormId] = useState<string | null>(null);
const [selectedRecipientId, setSelectedRecipientId] = useState<number | null>(null);
const form = useForm<TEditorFieldsFormSchema>({
defaultValues: {
fields: envelope.fields.map(
(field): TLocalField => ({
id: field.id,
formId: nanoid(),
envelopeItemId: field.envelopeItemId,
page: field.page,
type: field.type,
positionX: Number(field.positionX),
positionY: Number(field.positionY),
width: Number(field.width),
height: Number(field.height),
recipientId: field.recipientId,
fieldMeta: field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined,
}),
),
},
resolver: zodResolver(ZEditorFieldsFormSchema),
});
const {
append,
remove,
update,
fields: localFields,
} = useFieldArray({
control: form.control,
name: 'fields',
keyName: 'react-hook-form-id',
});
const triggerFieldsUpdate = () => {
void handleFieldsUpdate(form.getValues().fields);
};
const setSelectedField = (formId: string | null, bypassCheck = false) => {
if (!formId) {
setSelectedFieldFormId(null);
return;
}
const foundField = localFields.find((field) => field.formId === formId);
const recipient = envelope.recipients.find(
(recipient) => recipient.id === foundField?.recipientId,
);
if (recipient) {
setSelectedRecipient(recipient.id);
}
if (bypassCheck) {
console.log(3);
setSelectedFieldFormId(formId);
return;
}
setSelectedFieldFormId(foundField?.formId ?? null);
};
const addField = useCallback(
(fieldData: Omit<TLocalField, 'formId'>): TLocalField => {
const field: TLocalField = {
...fieldData,
formId: nanoid(12),
};
append(field);
triggerFieldsUpdate();
setSelectedField(field.formId, true);
return field;
},
[append, triggerFieldsUpdate, setSelectedField],
);
const removeFieldsByFormId = useCallback(
(formIds: string[]) => {
const indexes = formIds
.map((formId) => localFields.findIndex((field) => field.formId === formId))
.filter((index) => index !== -1);
if (indexes.length > 0) {
remove(indexes);
triggerFieldsUpdate();
}
},
[localFields, remove, triggerFieldsUpdate],
);
const updateFieldByFormId = useCallback(
(formId: string, updates: Partial<TLocalField>) => {
const index = localFields.findIndex((field) => field.formId === formId);
if (index !== -1) {
update(index, { ...localFields[index], ...updates });
triggerFieldsUpdate();
}
},
[localFields, update, triggerFieldsUpdate],
);
const duplicateField = useCallback(
(field: TLocalField): TLocalField => {
const newField: TLocalField = {
...structuredClone(field),
id: undefined,
formId: nanoid(12),
recipientId: field.recipientId,
positionX: field.positionX + 3,
positionY: field.positionY + 3,
};
append(newField);
triggerFieldsUpdate();
return newField;
},
[append, triggerFieldsUpdate],
);
const duplicateFieldToAllPages = useCallback(
(field: TLocalField): TLocalField[] => {
const pages = Array.from(document.querySelectorAll('[data-page-number]'));
const newFields: TLocalField[] = [];
pages.forEach((_, index) => {
const pageNumber = index + 1;
if (pageNumber === field.page) {
return;
}
const newField: TLocalField = {
...structuredClone(field),
id: undefined,
formId: nanoid(12),
page: pageNumber,
};
append(newField);
newFields.push(newField);
});
triggerFieldsUpdate();
return newFields;
},
[append, triggerFieldsUpdate],
);
const getFieldByFormId = useCallback(
(formId: string): TLocalField | undefined => {
return localFields.find((field) => field.formId === formId) as TLocalField | undefined;
},
[localFields],
);
const getFieldsByRecipient = useCallback(
(recipientId: number): TLocalField[] => {
return localFields.filter((field) => field.recipientId === recipientId);
},
[localFields],
);
const selectedRecipient = useMemo(() => {
return envelope.recipients.find((recipient) => recipient.id === selectedRecipientId) || null;
}, [selectedRecipientId, envelope.recipients]);
const selectedField = useMemo(() => {
return localFields.find((field) => field.formId === selectedFieldFormId);
}, [selectedFieldFormId, localFields]);
/**
* Keep the selected field form ID in sync with the local fields.
*/
useEffect(() => {
const foundField = localFields.find((field) => field.formId === selectedFieldFormId);
setSelectedFieldFormId(foundField?.formId ?? null);
}, [selectedFieldFormId, localFields]);
const setSelectedRecipient = (recipientId: number | null) => {
const foundRecipient = envelope.recipients.find((recipient) => recipient.id === recipientId);
setSelectedRecipientId(foundRecipient?.id ?? null);
};
return {
// Core state
localFields,
// Field operations
addField,
removeFieldsByFormId,
updateFieldByFormId,
duplicateField,
duplicateFieldToAllPages,
// Field utilities
getFieldByFormId,
getFieldsByRecipient,
// Selected field
selectedField,
setSelectedField,
// Selected recipient
selectedRecipient,
setSelectedRecipient,
};
};

View File

@ -0,0 +1,90 @@
import { useCallback, useEffect, useRef, useState } from 'react';
export function useEnvelopeAutosave<T>(saveFn: (data: T) => Promise<void>, delay = 1000) {
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastArgsRef = useRef<T | null>(null);
const pendingPromiseRef = useRef<Promise<void> | null>(null);
const [isPending, setIsPending] = useState(false);
const [isCommiting, setIsCommiting] = useState(false);
const triggerSave = useCallback(
(data: T) => {
lastArgsRef.current = data;
// A debounce or promise means something is pending
setIsPending(true);
if (timeoutRef.current) clearTimeout(timeoutRef.current);
// eslint-disable-next-line @typescript-eslint/no-misused-promises
timeoutRef.current = setTimeout(async () => {
if (!lastArgsRef.current) {
return;
}
const args = lastArgsRef.current;
lastArgsRef.current = null;
timeoutRef.current = null;
setIsCommiting(true);
pendingPromiseRef.current = saveFn(args);
try {
await pendingPromiseRef.current;
} finally {
// eslint-disable-next-line require-atomic-updates
pendingPromiseRef.current = null;
setIsCommiting(false);
setIsPending(false);
}
}, delay);
},
[saveFn, delay],
);
const flush = useCallback(async () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
if (pendingPromiseRef.current) {
// Already running → wait for it
await pendingPromiseRef.current;
return;
}
if (lastArgsRef.current) {
const args = lastArgsRef.current;
lastArgsRef.current = null;
setIsCommiting(true);
setIsPending(true);
pendingPromiseRef.current = saveFn(args);
try {
await pendingPromiseRef.current;
} finally {
// eslint-disable-next-line require-atomic-updates
pendingPromiseRef.current = null;
setIsCommiting(false);
setIsPending(false);
}
}
}, [saveFn]);
useEffect(() => {
const handleBeforeUnload = () => {
if (timeoutRef.current || pendingPromiseRef.current) {
void flush();
}
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
}, [flush]);
return { triggerSave, flush, isPending, isCommiting };
}

View File

@ -5,7 +5,9 @@ import type { Field } from '@prisma/client';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
export const useFieldPageCoords = (field: Field) => {
export const useFieldPageCoords = (
field: Pick<Field, 'positionX' | 'positionY' | 'width' | 'height' | 'page'>,
) => {
const [coords, setCoords] = useState({
x: 0,
y: 0,

View File

@ -0,0 +1,286 @@
import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react';
import { useLingui } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import { trpc } from '@documenso/trpc/react';
import type { TSetEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/set-envelope-recipients.types';
import type { RecipientColorStyles, TRecipientColor } from '@documenso/ui/lib/recipient-colors';
import {
AVAILABLE_RECIPIENT_COLORS,
getRecipientColorStyles,
} from '@documenso/ui/lib/recipient-colors';
import { useToast } from '@documenso/ui/primitives/use-toast';
import type { TDocumentEmailSettings } from '../../types/document-email';
import type { TEnvelope } from '../../types/envelope';
import { useEditorFields } from '../hooks/use-editor-fields';
import type { TLocalField } from '../hooks/use-editor-fields';
import { useEnvelopeAutosave } from '../hooks/use-envelope-autosave';
export const useDebounceFunction = <Args extends unknown[]>(
callback: (...args: Args) => void,
delay: number,
) => {
const timeoutRef = useRef<NodeJS.Timeout>();
return useCallback(
(...args: Args) => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
callback(...args);
}, delay);
},
[callback, delay],
);
};
type EnvelopeEditorProviderValue = {
envelope: TEnvelope;
isDocument: boolean;
isTemplate: boolean;
setLocalEnvelope: (localEnvelope: Partial<TEnvelope>) => void;
updateEnvelope: (envelopeUpdates: Partial<TEnvelope>) => void;
setRecipientsDebounced: (recipients: TSetEnvelopeRecipientsRequest['recipients']) => void;
setRecipientsAsync: (recipients: TSetEnvelopeRecipientsRequest['recipients']) => Promise<void>;
getFieldColor: (field: TLocalField) => RecipientColorStyles;
getRecipientColorKey: (recipientId: number) => TRecipientColor;
editorFields: ReturnType<typeof useEditorFields>;
isAutosaving: boolean;
flushAutosave: () => void;
autosaveError: boolean;
// refetchEnvelope: () => Promise<void>;
// updateEnvelope: (envelope: TEnvelope) => Promise<void>;
};
interface EnvelopeEditorProviderProps {
children: React.ReactNode;
initialEnvelope: TEnvelope;
}
const EnvelopeEditorContext = createContext<EnvelopeEditorProviderValue | null>(null);
export const useCurrentEnvelopeEditor = () => {
const context = useContext(EnvelopeEditorContext);
if (!context) {
throw new Error('useCurrentEnvelopeEditor must be used within a EnvelopeEditorProvider');
}
return context;
};
export const EnvelopeEditorProvider = ({
children,
initialEnvelope,
}: EnvelopeEditorProviderProps) => {
const { t } = useLingui();
const { toast } = useToast();
const [envelope, setEnvelope] = useState(initialEnvelope);
const [autosaveError, setAutosaveError] = useState<boolean>(false);
const envelopeUpdateMutationQuery = trpc.envelope.update.useMutation({
onSuccess: (response, input) => {
console.log(input.meta?.emailSettings);
setEnvelope({
...envelope,
...response,
documentMeta: {
...envelope.documentMeta,
...input.meta,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
emailSettings: (input.meta?.emailSettings ||
null) as unknown as TDocumentEmailSettings | null,
},
});
setAutosaveError(false);
},
onError: (error) => {
setAutosaveError(true);
toast({
title: t`Save failed`,
description: t`We encountered an error while attempting to save your changes. Your changes cannot be saved at this time.`,
variant: 'destructive',
duration: 7500,
});
},
});
const envelopeFieldSetMutationQuery = trpc.envelope.field.set.useMutation({
onSuccess: () => {
setAutosaveError(false);
},
onError: (error) => {
setAutosaveError(true);
toast({
title: t`Save failed`,
description: t`We encountered an error while attempting to save your changes. Your changes cannot be saved at this time.`,
variant: 'destructive',
duration: 7500,
});
},
});
const envelopeRecipientSetMutationQuery = trpc.envelope.recipient.set.useMutation({
onSuccess: () => {
setAutosaveError(false);
},
onError: (error) => {
setAutosaveError(true);
toast({
title: t`Save failed`,
description: t`We encountered an error while attempting to save your changes. Your changes cannot be saved at this time.`,
variant: 'destructive',
duration: 7500,
});
},
});
const {
triggerSave: setRecipientsDebounced,
flush: setRecipientsAsync,
isPending: isRecipientsMutationPending,
} = useEnvelopeAutosave(async (recipients: TSetEnvelopeRecipientsRequest['recipients']) => {
await envelopeRecipientSetMutationQuery.mutateAsync({
envelopeId: envelope.id,
envelopeType: envelope.type,
recipients,
});
}, 1000);
const {
triggerSave: setFieldsDebounced,
flush: setFieldsAsync,
isPending: isFieldsMutationPending,
} = useEnvelopeAutosave(async (fields: TLocalField[]) => {
await envelopeFieldSetMutationQuery.mutateAsync({
envelopeId: envelope.id,
envelopeType: envelope.type,
fields,
});
}, 1000);
const {
triggerSave: setEnvelopeDebounced,
flush: setEnvelopeAsync,
isPending: isEnvelopeMutationPending,
} = useEnvelopeAutosave(async (envelopeUpdates: Partial<TEnvelope>) => {
await envelopeUpdateMutationQuery.mutateAsync({
envelopeId: envelope.id,
envelopeType: envelope.type,
data: {
...envelopeUpdates,
},
});
}, 1000);
/**
* Updates the local envelope and debounces the update to the server.
*/
const updateEnvelope = (envelopeUpdates: Partial<TEnvelope>) => {
setEnvelope((prev) => ({ ...prev, ...envelopeUpdates }));
setEnvelopeDebounced(envelopeUpdates);
};
const editorFields = useEditorFields({
envelope,
handleFieldsUpdate: (fields) => setFieldsDebounced(fields),
});
const getFieldColor = useCallback(
(field: TLocalField) => {
// Todo: Envelopes - Local recipients
const recipientIndex = envelope.recipients.findIndex(
(recipient) => recipient.id === field.recipientId,
);
return getRecipientColorStyles(Math.max(recipientIndex, 0));
},
[envelope.recipients], // Todo: Envelopes - Local recipients
);
const getRecipientColorKey = useCallback(
(recipientId: number) => {
// Todo: Envelopes - Local recipients
const recipientIndex = envelope.recipients.findIndex(
(recipient) => recipient.id === recipientId,
);
return AVAILABLE_RECIPIENT_COLORS[Math.max(recipientIndex, 0)];
},
[envelope.recipients], // Todo: Envelopes - Local recipients
);
const { refetch: reloadEnvelope, isLoading: isReloadingEnvelope } = trpc.envelope.get.useQuery(
{
envelopeId: envelope.id,
},
{
initialData: envelope,
},
);
const setLocalEnvelope = (localEnvelope: Partial<TEnvelope>) => {
setEnvelope((prev) => ({ ...prev, ...localEnvelope }));
};
const isAutosaving = useMemo(() => {
return (
envelopeFieldSetMutationQuery.isPending ||
envelopeRecipientSetMutationQuery.isPending ||
envelopeUpdateMutationQuery.isPending ||
isFieldsMutationPending ||
isRecipientsMutationPending ||
isEnvelopeMutationPending
);
}, [
envelopeFieldSetMutationQuery.isPending,
envelopeRecipientSetMutationQuery.isPending,
envelopeUpdateMutationQuery.isPending,
isFieldsMutationPending,
isRecipientsMutationPending,
isEnvelopeMutationPending,
]);
const flushAutosave = () => {
void setFieldsAsync();
void setRecipientsAsync();
void setEnvelopeAsync();
};
return (
<EnvelopeEditorContext.Provider
value={{
envelope,
isDocument: envelope.type === EnvelopeType.DOCUMENT,
isTemplate: envelope.type === EnvelopeType.TEMPLATE,
setLocalEnvelope,
getFieldColor,
getRecipientColorKey,
updateEnvelope,
setRecipientsDebounced,
setRecipientsAsync,
editorFields,
autosaveError,
flushAutosave,
isAutosaving,
}}
>
{children}
</EnvelopeEditorContext.Provider>
);
};

View File

@ -0,0 +1,148 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import React from 'react';
import type { DocumentData } from '@prisma/client';
import type { TEnvelope } from '../../types/envelope';
import { getFile } from '../../universal/upload/get-file';
type FileData =
| {
status: 'loading' | 'error';
}
| {
file: Uint8Array;
status: 'loaded';
};
type EnvelopeRenderItem = TEnvelope['envelopeItems'][number];
type EnvelopeRenderProviderValue = {
getPdfBuffer: (documentDataId: string) => FileData | null;
envelopeItems: EnvelopeRenderItem[];
currentEnvelopeItem: EnvelopeRenderItem | null;
setCurrentEnvelopeItem: (envelopeItemId: string) => void;
fields: TEnvelope['fields'];
};
interface EnvelopeRenderProviderProps {
children: React.ReactNode;
envelope: Pick<TEnvelope, 'envelopeItems'>;
/**
* Optional fields which are passed down to renderers for custom rendering needs.
*
* Only pass if the CustomRenderer you are passing in wants fields.
*/
fields?: TEnvelope['fields'];
}
const EnvelopeRenderContext = createContext<EnvelopeRenderProviderValue | null>(null);
export const useCurrentEnvelopeRender = () => {
const context = useContext(EnvelopeRenderContext);
if (!context) {
throw new Error('useCurrentEnvelopeRender must be used within a EnvelopeRenderProvider');
}
return context;
};
/**
* Manages fetching and storing PDF files to render on the client.
*/
export const EnvelopeRenderProvider = ({
children,
envelope,
fields,
}: EnvelopeRenderProviderProps) => {
// Indexed by documentDataId.
const [files, setFiles] = useState<Record<string, FileData>>({});
const [currentItem, setItem] = useState<EnvelopeRenderItem | null>(null);
const envelopeItems = useMemo(
() => envelope.envelopeItems.sort((a, b) => a.order - b.order),
[envelope.envelopeItems],
);
const loadEnvelopeItemPdfFile = async (documentData: DocumentData) => {
if (files[documentData.id]?.status === 'loading') {
return;
}
if (!files[documentData.id]) {
setFiles((prev) => ({
...prev,
[documentData.id]: {
status: 'loading',
},
}));
}
try {
const file = await getFile(documentData);
setFiles((prev) => ({
...prev,
[documentData.id]: {
file,
status: 'loaded',
},
}));
} catch (error) {
console.error(error);
setFiles((prev) => ({
...prev,
[documentData.id]: {
status: 'error',
},
}));
}
};
const getPdfBuffer = useCallback(
(documentDataId: string) => {
return files[documentDataId] || null;
},
[files],
);
const setCurrentEnvelopeItem = (envelopeItemId: string) => {
const foundItem = envelope.envelopeItems.find((item) => item.id === envelopeItemId);
setItem(foundItem ?? null);
};
// Set the selected item to the first item if none is set.
useEffect(() => {
if (!currentItem && envelopeItems.length > 0) {
setCurrentEnvelopeItem(envelopeItems[0].id);
}
}, [currentItem, envelopeItems]);
// Look for any missing pdf files and load them.
useEffect(() => {
const missingFiles = envelope.envelopeItems.filter((item) => !files[item.documentDataId]);
for (const item of missingFiles) {
void loadEnvelopeItemPdfFile(item.documentData);
}
}, [envelope.envelopeItems]);
return (
<EnvelopeRenderContext.Provider
value={{
getPdfBuffer,
envelopeItems,
currentEnvelopeItem: currentItem,
setCurrentEnvelopeItem,
fields: fields ?? [],
}}
>
{children}
</EnvelopeRenderContext.Provider>
);
};