diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx index 88a562630..ac56a9c3a 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx @@ -323,14 +323,17 @@ export const EnvelopeEditorRecipientForm = () => { const { data } = validatedFormValues; - // Weird edge case where the whole envelope is created via API - // with no signing order. If they come to this page it will show an error - // since they aren't equal and the recipient is no longer editable. + // Locked recipients hold persisted values the server refuses to rewrite, + // e.g. an envelope created via API with no signing order where a recipient + // has already signed. Restore their PERSISTED order so form normalization + // drift never submits a "changed" locked recipient the server rejects. const envelopeRecipients = data.signers.map((recipient) => { if (!canRecipientBeModified(recipient.id)) { + const persistedRecipient = recipients.find((envelopeRecipient) => envelopeRecipient.id === recipient.id); + return { ...recipient, - signingOrder: recipient.signingOrder, + signingOrder: persistedRecipient?.signingOrder ?? undefined, }; } return recipient; @@ -356,7 +359,7 @@ export const EnvelopeEditorRecipientForm = () => { signer.email !== recipient.email || signer.name !== recipient.name || signer.role !== recipient.role || - signer.signingOrder !== recipient.signingOrder || + (signer.signingOrder ?? null) !== (recipient.signingOrder ?? null) || !isDeepEqual(signerActionAuth, recipientActionAuth) ); }); diff --git a/packages/lib/client-only/hooks/use-editor-recipients.ts b/packages/lib/client-only/hooks/use-editor-recipients.ts index 3d0f6177a..fc1fd2532 100644 --- a/packages/lib/client-only/hooks/use-editor-recipients.ts +++ b/packages/lib/client-only/hooks/use-editor-recipients.ts @@ -3,14 +3,14 @@ import { ZRecipientActionAuthTypesSchema, ZRecipientAuthOptionsSchema } from '@d import type { TEditorEnvelope } from '@documenso/lib/types/envelope-editor'; import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient'; import { zodResolver } from '@hookform/resolvers/zod'; -import { DocumentSigningOrder, RecipientRole } from '@prisma/client'; +import { DocumentSigningOrder, EnvelopeType, RecipientRole } from '@prisma/client'; import { useId } from 'react'; import type { UseFormReturn } from 'react-hook-form'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; import { normalizeGroupedSigningOrders } from '../../utils/recipient-groups'; -import { isCcRecipient, sortRecipientsForSigningOrder } from '../../utils/recipients'; +import { canRecipientBeModified, isCcRecipient, sortRecipientsForSigningOrder } from '../../utils/recipients'; const LocalRecipientSchema = z.object({ formId: z.string().min(1), @@ -154,6 +154,24 @@ export const useEditorRecipients = ({ envelope }: EditorRecipientsProps): UseEdi const sourceRecipients = sortRecipientsForSigningOrder(recipients || envelope.recipients); + // Locked recipients hold persisted values the server refuses to rewrite. + // Initialization must never assign or renumber their signing orders — + // doing so makes the very first autosave submit a "changed" locked + // recipient, which the server rejects on every subsequent save. + const isRecipientLocked = (recipientId: number) => { + if (envelope.type === EnvelopeType.TEMPLATE) { + return false; + } + + const persistedRecipient = sourceRecipients.find((recipient) => recipient.id === recipientId); + + if (!persistedRecipient) { + return false; + } + + return !canRecipientBeModified(persistedRecipient, envelope.fields); + }; + // A recipient without a persisted order means "last" everywhere else — the // server sorts NULLS LAST. Continue numbering after the highest existing // order rather than guessing from array position: a guess can land on a @@ -170,6 +188,10 @@ export const useEditorRecipients = ({ envelope }: EditorRecipientsProps): UseEdi signingOrderByRecipientId.set(recipient.id, undefined); } else if (typeof recipient.signingOrder === 'number') { signingOrderByRecipientId.set(recipient.id, recipient.signingOrder); + } else if (isRecipientLocked(recipient.id)) { + // A locked null order must round-trip as-is: a synthetic number would + // read as a change to a recipient the server refuses to modify. + signingOrderByRecipientId.set(recipient.id, undefined); } else { fallbackOrder += 1; signingOrderByRecipientId.set(recipient.id, fallbackOrder); @@ -188,7 +210,7 @@ export const useEditorRecipients = ({ envelope }: EditorRecipientsProps): UseEdi const signers: TLocalRecipient[] = formRecipients.length > 0 - ? normalizeGroupedSigningOrders(formRecipients) + ? normalizeGroupedSigningOrders(formRecipients, (formRecipient) => !isRecipientLocked(formRecipient.id)) : [ { formId: initialId, diff --git a/packages/lib/server-only/recipient/set-document-recipients.ts b/packages/lib/server-only/recipient/set-document-recipients.ts index 3227e3296..f4ea37ef8 100644 --- a/packages/lib/server-only/recipient/set-document-recipients.ts +++ b/packages/lib/server-only/recipient/set-document-recipients.ts @@ -348,7 +348,9 @@ const hasRecipientBeenChanged = (recipient: Recipient, newRecipientData: Recipie recipient.email !== newRecipientData.email || recipient.name !== newRecipientData.name || recipient.role !== newRecipientData.role || - recipient.signingOrder !== newRecipientData.signingOrder || + // Null and undefined both mean "no order": the request schema cannot + // carry null, so a persisted null arrives as undefined + (recipient.signingOrder ?? null) !== (newRecipientData.signingOrder ?? null) || !isDeepEqual(authOptions.accessAuth, newRecipientAccessAuth) || !isDeepEqual(authOptions.actionAuth, newRecipientActionAuth) );