From 0768a2a3200ee398129a8242146cf6b2f71eaac5 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 28 Aug 2026 18:28:39 +1000 Subject: [PATCH] fix: reviewed --- .../envelope-editor-recipient-form.tsx | 564 ++---------------- .../document/complete-document-with-token.ts | 138 +++-- .../create-document-from-direct-template.ts | 27 +- 3 files changed, 139 insertions(+), 590 deletions(-) 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 cff9bab64..88a562630 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 @@ -1,42 +1,30 @@ import { useLimits } from '@documenso/ee/server-only/limits/provider/client'; -import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value'; -import { ZEditorRecipientsFormSchema } from '@documenso/lib/client-only/hooks/use-editor-recipients'; +import { + updateEditorSigners, + ZEditorRecipientsFormSchema, +} from '@documenso/lib/client-only/hooks/use-editor-recipients'; import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider'; import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { useOptionalSession } from '@documenso/lib/client-only/providers/session'; import type { TDetectedRecipientSchema } from '@documenso/lib/server-only/ai/envelope/detect-recipients/schema'; import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth'; import { nanoid } from '@documenso/lib/universal/id'; -import { - isAssistantLastSigner, - isCcRecipient, - normalizeRecipientSigningOrders, - canRecipientBeModified as utilCanRecipientBeModified, -} from '@documenso/lib/utils/recipients'; -import { trpc } from '@documenso/trpc/react'; -import { RecipientActionAuthSelect } from '@documenso/ui/components/recipient/recipient-action-auth-select'; -import { - RecipientAutoCompleteInput, - type RecipientAutoCompleteOption, -} from '@documenso/ui/components/recipient/recipient-autocomplete-input'; -import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select'; +import { groupRecipientsBySigningOrder, normalizeGroupedSigningOrders } from '@documenso/lib/utils/recipient-groups'; +import { canEditorRecipientBeModified } from '@documenso/lib/utils/recipients'; import { cn } from '@documenso/ui/lib/utils'; import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; import { Button } from '@documenso/ui/primitives/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card'; import { Checkbox } from '@documenso/ui/primitives/checkbox'; import { SigningOrderConfirmation } from '@documenso/ui/primitives/document-flow/signing-order-confirmation'; -import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form'; +import { Form, FormControl, FormField, FormItem, FormLabel } from '@documenso/ui/primitives/form/form'; import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message'; -import { Input } from '@documenso/ui/primitives/input'; import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip'; import { useToast } from '@documenso/ui/primitives/use-toast'; -import { DragDropContext, Draggable, Droppable, type DropResult, type SensorAPI } from '@hello-pangea/dnd'; import { plural } from '@lingui/core/macro'; -import { Trans, useLingui } from '@lingui/react/macro'; -import { DocumentSigningOrder, EnvelopeType, RecipientRole, SendStatus } from '@prisma/client'; -import { motion } from 'framer-motion'; -import { GripVerticalIcon, HelpCircleIcon, PlusIcon, SparklesIcon, TrashIcon } from 'lucide-react'; +import { Trans } from '@lingui/react/macro'; +import { DocumentSigningOrder, RecipientRole, SendStatus } from '@prisma/client'; +import { HelpCircleIcon, PlusIcon, SparklesIcon } from 'lucide-react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useFieldArray, useWatch } from 'react-hook-form'; import { useRevalidator, useSearchParams } from 'react-router'; @@ -46,6 +34,8 @@ import { AiFeaturesEnableDialog } from '~/components/dialogs/ai-features-enable- import { AiRecipientDetectionDialog } from '~/components/dialogs/ai-recipient-detection-dialog'; import { useCurrentTeam } from '~/providers/team'; +import { RecipientStepList } from './recipient-step-list'; + export const EnvelopeEditorRecipientForm = () => { const { envelope, setRecipientsDebounced, updateEnvelope, editorRecipients, isEmbedded, editorConfig } = useCurrentEnvelopeEditor(); @@ -53,7 +43,6 @@ export const EnvelopeEditorRecipientForm = () => { const organisation = useCurrentOrganisation(); const team = useCurrentTeam(); - const { t } = useLingui(); const { toast } = useToast(); const { remaining } = useLimits(); const { sessionData } = useOptionalSession(); @@ -61,7 +50,6 @@ export const EnvelopeEditorRecipientForm = () => { const user = sessionData?.user; const [searchParams, setSearchParams] = useSearchParams(); - const [recipientSearchQuery, setRecipientSearchQuery] = useState(''); const [isAiEnableDialogOpen, setIsAiEnableDialogOpen] = useState(false); // AI recipient detection dialog state @@ -107,23 +95,8 @@ export const EnvelopeEditorRecipientForm = () => { }); }; - const debouncedRecipientSearchQuery = useDebouncedValue(recipientSearchQuery, 500); - - const $sensorApi = useRef(null); const isFirstRender = useRef(true); - const { recipients, fields } = envelope; - - const { data: recipientSuggestionsData, isLoading } = trpc.recipient.suggestions.find.useQuery( - { - query: debouncedRecipientSearchQuery, - }, - { - enabled: debouncedRecipientSearchQuery.length > 1 && !isEmbedded, - retry: false, - }, - ); - - const recipientSuggestions = recipientSuggestionsData?.results || []; + const { recipients } = envelope; const { form } = editorRecipients; @@ -161,17 +134,20 @@ export const EnvelopeEditorRecipientForm = () => { }, [watchedSigners]); const normalizeSigningOrders = (signers: typeof watchedSigners) => { - return normalizeRecipientSigningOrders(signers, (signer) => canRecipientBeModified(signer.id)); + return normalizeGroupedSigningOrders(signers, (signer) => canRecipientBeModified(signer.id)); }; - const activeRecipientCount = watchedSigners.filter((signer) => !isCcRecipient(signer)).length; - - const { fields: signers, remove: removeSigner } = useFieldArray({ + // Keep a mounted field array for `signers` so react-hook-form reconciles + // whole-array `setValue` calls atomically. Without it, reordering the array + // leaves stale partial entries in watched values (missing email/name/role), + // which breaks validation and the autosave sync. + useFieldArray({ control, name: 'signers', - keyName: 'nativeId', }); + const stepCount = useMemo(() => groupRecipientsBySigningOrder(watchedSigners).steps.length, [watchedSigners]); + const emptySignerIndex = watchedSigners.findIndex( (signer) => !signer.name && !signer.email && envelope.fields.filter((field) => field.recipientId === signer.id).length === 0, @@ -183,39 +159,22 @@ export const EnvelopeEditorRecipientForm = () => { const hasCurrentEditorInfo = Boolean(currentEditorEmail || currentEditorName); + // Note: Watched signer entries can be transiently partial while react-hook-form + // re-registers reordered array fields, so guard optional access here. const isUserAlreadyARecipient = watchedSigners.some( - (signer) => signer.email.toLowerCase() === currentEditorEmail?.toLowerCase(), + (signer) => Boolean(currentEditorEmail) && signer.email?.toLowerCase() === currentEditorEmail?.toLowerCase(), ); const hasDocumentBeenSent = recipients.some( (recipient) => recipient.role !== RecipientRole.CC && recipient.sendStatus === SendStatus.SENT, ); - const canRecipientBeModified = (recipientId?: number) => { - if (envelope.type === EnvelopeType.TEMPLATE) { - return true; - } - - if (recipientId === undefined) { - return true; - } - - const recipient = recipients.find((recipient) => recipient.id === recipientId); - - if (!recipient) { - return false; - } - - return utilCanRecipientBeModified(recipient, fields); - }; + const canRecipientBeModified = (recipientId?: number) => canEditorRecipientBeModified(envelope, recipientId); const appendNormalizedSigner = (signer: (typeof watchedSigners)[number], shouldFocus = false) => { const updatedSigners = normalizeSigningOrders([...form.getValues('signers'), signer]); - form.setValue('signers', updatedSigners, { - shouldValidate: true, - shouldDirty: true, - }); + updateEditorSigners(form, updatedSigners); if (shouldFocus) { const signerIndex = updatedSigners.findIndex((updatedSigner) => updatedSigner.formId === signer.formId); @@ -233,7 +192,7 @@ export const EnvelopeEditorRecipientForm = () => { email: '', role: RecipientRole.SIGNER, actionAuth: [], - signingOrder: activeRecipientCount + 1, + signingOrder: stepCount + 1, }); }; @@ -245,8 +204,8 @@ export const EnvelopeEditorRecipientForm = () => { // If the only signer is the default empty signer lets just replace it with the detected recipients if (currentSigners.length === 1 && !currentSigners[0].name && !currentSigners[0].email) { - form.setValue( - 'signers', + updateEditorSigners( + form, detectedRecipients.map((recipient, index) => ({ formId: nanoid(12), name: recipient.name, @@ -255,10 +214,6 @@ export const EnvelopeEditorRecipientForm = () => { actionAuth: [], signingOrder: index + 1, })), - { - shouldValidate: true, - shouldDirty: true, - }, ); return; @@ -285,10 +240,7 @@ export const EnvelopeEditorRecipientForm = () => { nextSigningOrder += 1; } - form.setValue('signers', normalizeSigningOrders(currentSigners), { - shouldValidate: true, - shouldDirty: true, - }); + updateEditorSigners(form, normalizeSigningOrders(currentSigners)); toast({ title: plural(detectedRecipients.length, { @@ -302,32 +254,6 @@ export const EnvelopeEditorRecipientForm = () => { }); }; - const onRemoveSigner = (index: number) => { - const signer = signers[index]; - - if (!canRecipientBeModified(signer.id)) { - toast({ - title: t`Cannot remove signer`, - description: t`This signer has already signed the document.`, - variant: 'destructive', - }); - - return; - } - - const formStateIndex = form.getValues('signers').findIndex((s) => s.formId === signer.formId); - if (formStateIndex !== -1) { - removeSigner(formStateIndex); - - const updatedSigners = form.getValues('signers').filter((s) => s.formId !== signer.formId); - - form.setValue('signers', normalizeSigningOrders(updatedSigners), { - shouldValidate: true, - shouldDirty: true, - }); - } - }; - const onAddSelfSigner = () => { if (emptySignerIndex !== -1) { setValue(`signers.${emptySignerIndex}.name`, currentEditorName ?? '', { @@ -348,7 +274,7 @@ export const EnvelopeEditorRecipientForm = () => { email: currentEditorEmail ?? '', role: RecipientRole.SIGNER, actionAuth: [], - signingOrder: activeRecipientCount + 1, + signingOrder: stepCount + 1, }, true, ); @@ -357,142 +283,6 @@ export const EnvelopeEditorRecipientForm = () => { } }; - const handleRecipientAutoCompleteSelect = (index: number, suggestion: RecipientAutoCompleteOption) => { - setValue(`signers.${index}.email`, suggestion.email, { - shouldValidate: true, - shouldDirty: true, - }); - setValue(`signers.${index}.name`, suggestion.name || '', { - shouldValidate: true, - shouldDirty: true, - }); - }; - - const onDragEnd = useCallback( - async (result: DropResult) => { - if (!result.destination) { - return; - } - - const items = Array.from(watchedSigners); - const [reorderedSigner] = items.splice(result.source.index, 1); - - // Find next valid position - let insertIndex = result.destination.index; - while (insertIndex < items.length && !canRecipientBeModified(items[insertIndex].id)) { - insertIndex++; - } - - items.splice(insertIndex, 0, reorderedSigner); - - const updatedSigners = normalizeSigningOrders(items); - - form.setValue('signers', updatedSigners, { - shouldValidate: true, - shouldDirty: true, - }); - - if (isAssistantLastSigner(updatedSigners)) { - toast({ - title: t`Warning: Assistant as last signer`, - description: t`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`, - }); - } - - await form.trigger('signers'); - }, - [form, canRecipientBeModified, watchedSigners, toast], - ); - - const handleRoleChange = useCallback( - (index: number, role: RecipientRole) => { - const currentSigners = form.getValues('signers'); - const signingOrder = form.getValues('signingOrder'); - - // Handle parallel to sequential conversion for assistants - if (role === RecipientRole.ASSISTANT && signingOrder === DocumentSigningOrder.PARALLEL) { - form.setValue('signingOrder', DocumentSigningOrder.SEQUENTIAL, { - shouldValidate: true, - shouldDirty: true, - }); - toast({ - title: t`Signing order is enabled.`, - description: t`You cannot add assistants when signing order is disabled.`, - variant: 'destructive', - }); - return; - } - - const updatedSigners = normalizeSigningOrders( - currentSigners.map((signer, idx) => ({ - ...signer, - role: idx === index ? role : signer.role, - })), - ); - - form.setValue('signers', updatedSigners, { - shouldValidate: true, - shouldDirty: true, - }); - - if (role === RecipientRole.ASSISTANT && isAssistantLastSigner(updatedSigners)) { - toast({ - title: t`Warning: Assistant as last signer`, - description: t`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`, - }); - } - }, - [form, toast, canRecipientBeModified], - ); - - const handleSigningOrderChange = useCallback( - (index: number, newOrderString: string) => { - const trimmedOrderString = newOrderString.trim(); - if (!trimmedOrderString) { - return; - } - - const newOrder = Number(trimmedOrderString); - if (!Number.isInteger(newOrder) || newOrder < 1) { - return; - } - - const currentSigners = form.getValues('signers'); - const signer = currentSigners[index]; - - if (isCcRecipient(signer)) { - return; - } - - const nonCcSigners = currentSigners.filter((s) => !isCcRecipient(s)); - const ccSigners = currentSigners.filter((s) => isCcRecipient(s)); - const currentSigningOrderIndex = nonCcSigners.findIndex((s) => s.formId === signer.formId); - - if (currentSigningOrderIndex === -1) { - return; - } - - const [reorderedSigner] = nonCcSigners.splice(currentSigningOrderIndex, 1); - const newPosition = Math.min(Math.max(0, newOrder - 1), nonCcSigners.length); - nonCcSigners.splice(newPosition, 0, reorderedSigner); - - const updatedSigners = normalizeSigningOrders([...nonCcSigners, ...ccSigners]); - - form.setValue('signers', updatedSigners, { - shouldValidate: true, - shouldDirty: true, - }); - - if (signer.role === RecipientRole.ASSISTANT && isAssistantLastSigner(updatedSigners)) { - toast({ - title: t`Warning: Assistant as last signer`, - description: t`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`, - }); - } - }, - [form, canRecipientBeModified, toast], - ); - const handleSigningOrderDisable = useCallback(() => { setShowSigningOrderConfirmation(false); @@ -504,10 +294,8 @@ export const EnvelopeEditorRecipientForm = () => { })), ); - form.setValue('signers', updatedSigners, { - shouldValidate: true, - shouldDirty: true, - }); + updateEditorSigners(form, updatedSigners); + form.setValue('signingOrder', DocumentSigningOrder.PARALLEL, { shouldValidate: true, shouldDirty: true, @@ -588,7 +376,7 @@ export const EnvelopeEditorRecipientForm = () => { }, [formValues]); const recipientCountLimit = organisation.organisationClaim.recipientCount; - const isOverRecipientLimit = recipientCountLimit > 0 && signers.length > recipientCountLimit; + const isOverRecipientLimit = recipientCountLimit > 0 && watchedSigners.length > recipientCountLimit; return ( @@ -644,7 +432,7 @@ export const EnvelopeEditorRecipientForm = () => { type="button" className="flex-1" size="sm" - disabled={isSubmitting || signers.length >= remaining.recipients} + disabled={isSubmitting || watchedSigners.length >= remaining.recipients} onClick={() => onAddSigner()} > @@ -794,287 +582,7 @@ export const EnvelopeEditorRecipientForm = () => { )} - { - $sensorApi.current = api; - }, - ]} - > - - {(provided) => ( -
- {signers.map((signer, index) => { - const isDirectRecipient = - envelope.type === EnvelopeType.TEMPLATE && - envelope.directLink !== null && - signer.id === envelope.directLink.directTemplateRecipientId; - - return ( - - {(provided, snapshot) => ( -
- -
- {isSigningOrderSequential && isCcRecipient(signer) && ( -
- )} - - {isSigningOrderSequential && !isCcRecipient(signer) && ( - ( - - - - { - field.onChange(e); - handleSigningOrderChange(index, e.target.value); - }} - onBlur={(e) => { - field.onBlur(); - handleSigningOrderChange(index, e.target.value); - }} - disabled={ - snapshot.isDragging || isSubmitting || !canRecipientBeModified(signer.id) - } - /> - - - - )} - /> - )} - - ( - - {!showAdvancedSettings && index === 0 && ( - - Email - - )} - - - - handleRecipientAutoCompleteSelect(index, suggestion) - } - onSearchQueryChange={(query) => { - field.onChange(query); - setRecipientSearchQuery(query); - }} - loading={isLoading} - data-testid="signer-email-input" - maxLength={254} - /> - - - - - )} - /> - - ( - - {!showAdvancedSettings && index === 0 && ( - - Name - - )} - - - - handleRecipientAutoCompleteSelect(index, suggestion) - } - onSearchQueryChange={(query) => { - field.onChange(query); - setRecipientSearchQuery(query); - }} - loading={isLoading} - maxLength={255} - /> - - - - - )} - /> - - ( - - - { - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - handleRoleChange(index, value as RecipientRole); - }} - disabled={ - snapshot.isDragging || isSubmitting || !canRecipientBeModified(signer.id) - } - /> - - - - - )} - /> - - -
- - {showAdvancedSettings && organisation.organisationClaim.flags.cfr21 && ( - ( - - - - - - - - )} - /> - )} - -
- )} - - ); - })} - - {provided.placeholder} -
- )} - - + { const envelope = await prisma.envelope.findFirst({ @@ -423,6 +424,7 @@ export const completeDocumentWithToken = async ({ select: { id: true, signingOrder: true, + signingStatus: true, name: true, email: true, role: true, @@ -451,65 +453,93 @@ export const completeDocumentWithToken = async ({ }); if (envelope.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL) { - const [nextRecipient] = pendingRecipients; + const nextRecipients = getRecipientsInActiveSigningStep(pendingRecipients); - await prisma.$transaction(async (tx) => { - if (nextSigner && envelope.documentMeta?.allowDictateNextSigner) { - await tx.documentAuditLog.create({ - data: createDocumentAuditLogData({ - type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED, - envelopeId: envelope.id, - user: { - name: recipientName, - email: recipientEmail, - }, - requestMetadata, + const currentRecipientOrder = recipient.signingOrder ?? Number.MAX_SAFE_INTEGER; + + const hasCompletedCurrentStep = nextRecipients.every( + (pendingRecipient) => (pendingRecipient.signingOrder ?? Number.MAX_SAFE_INTEGER) > currentRecipientOrder, + ); + + if (nextRecipients.length > 0 && hasCompletedCurrentStep) { + // Dictation is only allowed when advancing to a single-recipient step. + const canDictateNextSigner = + Boolean(dictatedNextSigner) && + Boolean(envelope.documentMeta?.allowDictateNextSigner) && + nextRecipients.length === 1; + + if (canDictateNextSigner && dictatedNextSigner) { + await prisma.$transaction(async (tx) => { + const [nextRecipient] = nextRecipients; + + await tx.recipient.update({ + where: { id: nextRecipient.id }, data: { - recipientEmail: nextRecipient.email, - recipientName: nextRecipient.name, - recipientId: nextRecipient.id, - recipientRole: nextRecipient.role, - changes: [ - { - type: RECIPIENT_DIFF_TYPE.NAME, - from: nextRecipient.name, - to: nextSigner.name, - }, - { - type: RECIPIENT_DIFF_TYPE.EMAIL, - from: nextRecipient.email, - to: nextSigner.email, - }, - ], + sendStatus: SendStatus.SENT, + sentAt: new Date(), + name: dictatedNextSigner.name, + email: dictatedNextSigner.email, }, - }), + }); + + await tx.documentAuditLog.create({ + data: createDocumentAuditLogData({ + type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED, + envelopeId: envelope.id, + user: { + name: recipientName, + email: recipientEmail, + }, + requestMetadata, + data: { + recipientEmail: nextRecipient.email, + recipientName: nextRecipient.name, + recipientId: nextRecipient.id, + recipientRole: nextRecipient.role, + changes: [ + { + type: RECIPIENT_DIFF_TYPE.NAME, + from: nextRecipient.name, + to: dictatedNextSigner.name, + }, + { + type: RECIPIENT_DIFF_TYPE.EMAIL, + from: nextRecipient.email, + to: dictatedNextSigner.email, + }, + ], + }, + }), + }); + }); + } else { + await prisma.recipient.updateMany({ + where: { + id: { + in: nextRecipients.map((nextRecipient) => nextRecipient.id), + }, + }, + data: { + sendStatus: SendStatus.SENT, + sentAt: new Date(), + }, }); } - await tx.recipient.update({ - where: { id: nextRecipient.id }, - data: { - sendStatus: SendStatus.SENT, - sentAt: new Date(), - ...(nextSigner && envelope.documentMeta?.allowDictateNextSigner - ? { - name: nextSigner.name, - email: nextSigner.email, - } - : {}), - }, - }); - }); - - await jobs.triggerJob({ - name: 'send.signing.requested.email', - payload: { - userId: envelope.userId, - documentId: legacyDocumentId, - recipientId: nextRecipient.id, - requestMetadata, - }, - }); + await Promise.allSettled( + nextRecipients.map((nextRecipient) => + jobs.triggerJob({ + name: 'send.signing.requested.email', + payload: { + userId: envelope.userId, + documentId: legacyDocumentId, + recipientId: nextRecipient.id, + requestMetadata, + }, + }), + ), + ); + } } } diff --git a/packages/lib/server-only/template/create-document-from-direct-template.ts b/packages/lib/server-only/template/create-document-from-direct-template.ts index e767170b7..bf7e25d59 100644 --- a/packages/lib/server-only/template/create-document-from-direct-template.ts +++ b/packages/lib/server-only/template/create-document-from-direct-template.ts @@ -40,6 +40,7 @@ import { extractDocumentAuthMethods, } from '../../utils/document-auth'; import { mapSecondaryIdToTemplateId } from '../../utils/envelope'; +import { getRecipientsInActiveSigningStep } from '../../utils/recipient-groups'; import { getRecipientsWithMissingFields } from '../../utils/recipients'; import { sendDocument } from '../document/send-document'; import { validateFieldAuth } from '../document/validate-field-auth'; @@ -676,6 +677,7 @@ export const createDocumentFromDirectTemplate = async ({ select: { id: true, signingOrder: true, + signingStatus: true, name: true, email: true, role: true, @@ -694,9 +696,22 @@ export const createDocumentFromDirectTemplate = async ({ orderBy: [{ signingOrder: { sort: 'asc', nulls: 'last' } }, { id: 'asc' }], }); - const nextRecipient = pendingRecipients[0]; + const nextRecipients = getRecipientsInActiveSigningStep(pendingRecipients); - if (nextRecipient) { + const directRecipientOrder = createdDirectRecipient.signingOrder ?? Number.MAX_SAFE_INTEGER; + + // The direct recipient can share a step with other recipients (a signing + // group). Those peers are still pending, so without this check they would + // look like the "next" step and be dictated over — dictation may only + // affect a strictly later step. + const hasCompletedCurrentStep = nextRecipients.every( + (pendingRecipient) => (pendingRecipient.signingOrder ?? Number.MAX_SAFE_INTEGER) > directRecipientOrder, + ); + + // Dictation can only apply when the next step is a single recipient. + const nextRecipient = hasCompletedCurrentStep && nextRecipients.length === 1 ? nextRecipients[0] : null; + + if (nextRecipient && documentMeta.allowDictateNextSigner) { auditLogsToCreate.push( createDocumentAuditLogData({ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED, @@ -730,12 +745,8 @@ export const createDocumentFromDirectTemplate = async ({ await tx.recipient.update({ where: { id: nextRecipient.id }, data: { - ...(nextSigner && documentMeta?.allowDictateNextSigner - ? { - name: nextSigner.name, - email: nextSigner.email, - } - : {}), + name: nextSigner.name, + email: nextSigner.email, }, }); }