diff --git a/apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx b/apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx index 179d3306a..ec01c4482 100644 --- a/apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx +++ b/apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx @@ -11,6 +11,7 @@ import { import type { TTemplate } from '@documenso/lib/types/template'; import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers'; import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields'; +import { getNextDictatableRecipient } from '@documenso/lib/utils/recipient-groups'; import type { TRemovedSignedFieldWithTokenMutationSchema, TSignFieldWithTokenMutationSchema, @@ -223,27 +224,12 @@ export const DirectTemplateSigningForm = ({ return undefined; } - const sortedRecipients = template.recipients.sort((a, b) => { - // Sort by signingOrder first (nulls last), then by id - if (a.signingOrder === null && b.signingOrder === null) { - return a.id - b.id; - } - if (a.signingOrder === null) { - return 1; - } - if (b.signingOrder === null) { - return -1; - } - if (a.signingOrder === b.signingOrder) { - return a.id - b.id; - } - return a.signingOrder - b.signingOrder; + const dictatableRecipients = getNextDictatableRecipient({ + recipients: template.recipients, + currentRecipientId: directRecipient.id, }); - const currentIndex = sortedRecipients.findIndex((r) => r.id === directRecipient.id); - return currentIndex !== -1 && currentIndex < sortedRecipients.length - 1 - ? sortedRecipients[currentIndex + 1] - : undefined; + return dictatableRecipients ?? undefined; }, [template.templateMeta?.signingOrder, template.recipients, directRecipient.id]); return ( diff --git a/apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx b/apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx index 41abb63f1..afe1b067a 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx @@ -100,8 +100,10 @@ export const DocumentSigningCompleteDialog = ({ const { isNameLocked, isEmailLocked } = useEmbedSigningContext() || {}; + const canDictateNextSigner = allowDictateNextSigner && Boolean(defaultNextSigner); + const form = useForm({ - resolver: allowDictateNextSigner ? zodResolver(ZNextSignerFormSchema) : undefined, + resolver: canDictateNextSigner ? zodResolver(ZNextSignerFormSchema) : undefined, defaultValues: { name: defaultNextSigner?.name ?? '', email: defaultNextSigner?.email ?? '', @@ -317,7 +319,7 @@ export const DocumentSigningCompleteDialog = ({
- {allowDictateNextSigner && defaultNextSigner && ( + {canDictateNextSigner && (
Promise; isSubmitting: boolean; fieldsValidated: () => void; - nextRecipient?: RecipientWithFields; + /** + * The dictatable next recipient, decided server-side. Only their identity + * is needed — for the dictation flag and the prefilled inputs. + */ + nextRecipient?: Pick; }; export const DocumentSigningForm = ({ @@ -84,6 +88,13 @@ export const DocumentSigningForm = ({ return fieldsRequiringValidation.filter((field) => field.recipientId === recipient.id); }, [fieldsRequiringValidation, recipient]); + // Single-sourced for every role branch: dictation is only offered when a + // dictatable next recipient exists — a branch missing the `nextRecipient` + // guard used to silently block viewers from completing. + const allowDictateNextSigner = Boolean(nextRecipient && document.documentMeta?.allowDictateNextSigner); + + const defaultNextSigner = nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined; + const localFieldsValidated = () => { setValidateUninsertedFields(true); fieldsValidated(); @@ -151,10 +162,8 @@ export const DocumentSigningForm = ({ completeDocument({ nextSigner, accessAuthOptions }) } recipient={recipient} - allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner} - defaultNextSigner={ - nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined - } + allowDictateNextSigner={allowDictateNextSigner} + defaultNextSigner={defaultNextSigner} />
@@ -223,8 +232,8 @@ export const DocumentSigningForm = ({ onClose={() => !isAssistantSubmitting && setIsConfirmationDialogOpen(false)} onConfirm={handleAssistantConfirmDialogSubmit} isSubmitting={isAssistantSubmitting} - allowDictateNextSigner={nextRecipient && document.documentMeta?.allowDictateNextSigner} - defaultNextSigner={nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined} + allowDictateNextSigner={allowDictateNextSigner} + defaultNextSigner={defaultNextSigner} />
) : ( @@ -291,10 +300,8 @@ export const DocumentSigningForm = ({ }) } recipient={recipient} - allowDictateNextSigner={nextRecipient && document.documentMeta?.allowDictateNextSigner} - defaultNextSigner={ - nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined - } + allowDictateNextSigner={allowDictateNextSigner} + defaultNextSigner={defaultNextSigner} /> diff --git a/apps/remix/app/components/general/document-signing/document-signing-page-view-v1.tsx b/apps/remix/app/components/general/document-signing/document-signing-page-view-v1.tsx index 1979c63a2..b4bee8d6e 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-page-view-v1.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-page-view-v1.tsx @@ -23,7 +23,7 @@ import { Button } from '@documenso/ui/primitives/button'; import { Card, CardContent } from '@documenso/ui/primitives/card'; import { ElementVisible } from '@documenso/ui/primitives/element-visible'; import { Trans } from '@lingui/react/macro'; -import type { Field } from '@prisma/client'; +import type { Field, Recipient } from '@prisma/client'; import { FieldType, RecipientRole } from '@prisma/client'; import { LucideChevronDown, LucideChevronUp } from 'lucide-react'; import { useMemo, useState } from 'react'; @@ -61,6 +61,12 @@ export type DocumentSigningPageViewV1Props = { completedFields: CompletedField[]; isRecipientsTurn: boolean; allRecipients?: RecipientWithFields[]; + /** + * The dictatable next recipient, computed server-side over the FULL + * recipient list — must not be re-derived from the role-scoped + * `allRecipients`. + */ + nextRecipient?: Pick; branding: DocumentSigningBranding; includeSenderDetails: boolean; }; @@ -72,6 +78,7 @@ export const DocumentSigningPageViewV1 = ({ completedFields, isRecipientsTurn, allRecipients = [], + nextRecipient, includeSenderDetails, branding, }: DocumentSigningPageViewV1Props) => { @@ -142,34 +149,6 @@ export const DocumentSigningPageViewV1 = ({ const selectedSigner = allRecipients?.find((r) => r.id === selectedSignerId); const targetSigner = recipient.role === RecipientRole.ASSISTANT && selectedSigner ? selectedSigner : null; - const nextRecipient = useMemo(() => { - if (!documentMeta?.signingOrder || documentMeta.signingOrder !== 'SEQUENTIAL') { - return undefined; - } - - const sortedRecipients = [...allRecipients].sort((a, b) => { - // Sort by signingOrder first (nulls last), then by id - if (a.signingOrder === null && b.signingOrder === null) { - return a.id - b.id; - } - if (a.signingOrder === null) { - return 1; - } - if (b.signingOrder === null) { - return -1; - } - if (a.signingOrder === b.signingOrder) { - return a.id - b.id; - } - return a.signingOrder - b.signingOrder; - }); - - const currentIndex = sortedRecipients.findIndex((r) => r.id === recipient.id); - return currentIndex !== -1 && currentIndex < sortedRecipients.length - 1 - ? sortedRecipients[currentIndex + 1] - : undefined; - }, [document.documentMeta?.signingOrder, allRecipients, recipient.id]); - const pendingFields = fieldsRequiringValidation.filter((field) => !field.inserted); const hasPendingFields = pendingFields.length > 0; diff --git a/apps/remix/app/components/general/document-signing/envelope-signing-provider.tsx b/apps/remix/app/components/general/document-signing/envelope-signing-provider.tsx index f0b00fec9..760cfdcaf 100644 --- a/apps/remix/app/components/general/document-signing/envelope-signing-provider.tsx +++ b/apps/remix/app/components/general/document-signing/envelope-signing-provider.tsx @@ -6,6 +6,7 @@ import type { EnvelopeForSigningResponse } from '@documenso/lib/server-only/enve import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import { isFieldUnsignedAndRequired, isRequiredField } from '@documenso/lib/utils/advanced-fields-helpers'; import { extractFieldInsertionValues } from '@documenso/lib/utils/envelope-signing'; +import { getNextDictatableRecipient } from '@documenso/lib/utils/recipient-groups'; import { trpc } from '@documenso/trpc/react'; import type { TSignEnvelopeFieldValue } from '@documenso/trpc/server/envelope-router/sign-envelope-field.types'; import { EnvelopeType, type Field, FieldType, type Recipient, RecipientRole, SigningStatus } from '@prisma/client'; @@ -290,32 +291,14 @@ export const EnvelopeSigningProvider = ({ .filter((field) => field.inserted); const nextRecipient = useMemo(() => { - if (!envelope.documentMeta.signingOrder || envelope.documentMeta.signingOrder !== 'SEQUENTIAL') { + if (envelope.documentMeta.signingOrder !== 'SEQUENTIAL') { return null; } - const sortedRecipients = [...envelope.recipients].sort((a, b) => { - // Sort by signingOrder first (nulls last), then by id - if (a.signingOrder === null && b.signingOrder === null) { - return a.id - b.id; - } - if (a.signingOrder === null) { - return 1; - } - if (b.signingOrder === null) { - return -1; - } - if (a.signingOrder === b.signingOrder) { - return a.id - b.id; - } - return a.signingOrder - b.signingOrder; + return getNextDictatableRecipient({ + recipients: envelope.recipients, + currentRecipientId: recipient.id, }); - - const currentIndex = sortedRecipients.findIndex((r) => r.id === recipient.id); - - return currentIndex !== -1 && currentIndex < sortedRecipients.length - 1 - ? sortedRecipients[currentIndex + 1] - : null; }, [envelope.documentMeta?.signingOrder, envelope.recipients, recipient.id]); const signField = async ( 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} -
- )} - - + void; + onRemove: (signerIndex: number) => void; + onAutoCompleteSelect: (signerIndex: number, suggestion: RecipientAutoCompleteOption) => void; + onSearchQueryChange: (query: string) => void; +}; + +const RecipientRowInner = ({ + signerIndex, + signer, + isSequential, + isInputDisabled, + canBeModified, + isRemoveDisabled, + showAdvancedSettings, + dragHandleProps, + recipientSuggestions, + isLoadingSuggestions, + onRoleChange, + onRemove, + onAutoCompleteSelect, + onSearchQueryChange, +}: RecipientRowProps) => { + const { t } = useLingui(); + + const { envelope, editorConfig } = useCurrentEnvelopeEditor(); + const organisation = useCurrentOrganisation(); + + const form = useFormContext(); + + const { isSubmitting } = form.formState; + + const isDirectRecipient = + envelope.type === EnvelopeType.TEMPLATE && + envelope.directLink !== null && + signer.id === envelope.directLink.directTemplateRecipientId; + + const isFieldDisabled = isInputDisabled || isSubmitting || !canBeModified; + + const rowErrors = form.formState.errors.signers?.[signerIndex]; + + return ( +
+
+ {isSequential && !isCcRecipient(signer) && ( + + + + )} + + ( + + + onAutoCompleteSelect(signerIndex, suggestion)} + onSearchQueryChange={(query) => { + field.onChange(query); + onSearchQueryChange(query); + }} + loading={isLoadingSuggestions} + data-testid="signer-email-input" + maxLength={254} + /> + + + + + )} + /> + + ( + + + onAutoCompleteSelect(signerIndex, suggestion)} + onSearchQueryChange={(query) => { + field.onChange(query); + onSearchQueryChange(query); + }} + loading={isLoadingSuggestions} + maxLength={255} + /> + + + + + )} + /> + + ( + + + { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + onRoleChange(signerIndex, value as RecipientRole); + }} + disabled={isFieldDisabled} + /> + + + + + )} + /> + + +
+ + {showAdvancedSettings && organisation.organisationClaim.flags.cfr21 && ( + ( + + + + + + + + )} + /> + )} +
+ ); +}; + +/** + * Memoized: rows contain heavy inputs (autocomplete, role select) and would + * otherwise re-render on every drag state change, making drags feel sluggish. + * All callback props are stable (useCallback in the list) and `signer` object + * identities only change when form values actually change. + */ +export const RecipientRow = memo(RecipientRowInner); diff --git a/apps/remix/app/components/general/envelope-editor/recipient-step-card.tsx b/apps/remix/app/components/general/envelope-editor/recipient-step-card.tsx new file mode 100644 index 000000000..fbad562c8 --- /dev/null +++ b/apps/remix/app/components/general/envelope-editor/recipient-step-card.tsx @@ -0,0 +1,255 @@ +import type { TEditorRecipientsFormSchema } from '@documenso/lib/client-only/hooks/use-editor-recipients'; +import type { RecipientStep } from '@documenso/lib/utils/recipient-groups'; +import { cn } from '@documenso/ui/lib/utils'; +import { Badge } from '@documenso/ui/primitives/badge'; +import { Button } from '@documenso/ui/primitives/button'; +import type { DraggableProvided, DraggableStateSnapshot } from '@hello-pangea/dnd'; +import { Draggable, Droppable } from '@hello-pangea/dnd'; +import { Trans } from '@lingui/react/macro'; +import { GripVerticalIcon, Users2Icon } from 'lucide-react'; + +import { RecipientRow, type RecipientRowProps } from './recipient-row'; + +type TEditorSigner = TEditorRecipientsFormSchema['signers'][number]; + +export type DraggingType = 'STEP' | 'RECIPIENT' | null; + +/** + * Skips the drop animation. The post-drop state update re-sorts and renumbers + * the groups anyway, so gliding to the predicted slot first makes every drop + * feel like it settles twice — snapping hands control to the real re-render + * immediately instead. + */ +const getDraggableStyle = (provided: DraggableProvided, snapshot: DraggableStateSnapshot) => { + if (!snapshot.isDropAnimating) { + return provided.draggableProps.style; + } + + return { + ...provided.draggableProps.style, + transitionDuration: '0.001s', + }; +}; + +export type RecipientStepCardSharedRowProps = Pick< + RecipientRowProps, + | 'showAdvancedSettings' + | 'recipientSuggestions' + | 'isLoadingSuggestions' + | 'onRoleChange' + | 'onRemove' + | 'onAutoCompleteSelect' + | 'onSearchQueryChange' +>; + +export type RecipientStepCardProps = { + stepIndex: number; + step: RecipientStep; + isLastStep: boolean; + draggableProvided: DraggableProvided; + draggableSnapshot: DraggableStateSnapshot; + draggingType: DraggingType; + /** + * Whether recipients may be combined into signing groups. False on CSC + * (AES/QES) instances, where every signing recipient must hold a distinct + * step. Constant for the session, so disabling the drop-zone with it does + * not violate the "never toggle `isDropDisabled` mid-drag" constraint. + */ + isGroupingEnabled: boolean; + isStepLocked: boolean; + isRemoveDisabled: boolean; + flatIndexByFormId: Map; + canSignerBeModified: (signer: TEditorSigner) => boolean; + isSubmitting: boolean; + onUngroup: (stepIndex: number) => void; + rowProps: RecipientStepCardSharedRowProps; +}; + +/** + * The drop-zone strip rendered above each group card (and below the last one) + * that receives recipient-row drops. Invisible until a dragged row hovers it, + * then it shows a full-width green line marking the insertion point. + * + * Notes: + * - It lives INSIDE the step's Draggable so it shifts together with the card + * while groups are being reordered — a static strip between draggables + * would stay behind while the cards around it are displaced, making group + * drags look broken. + * - Its `droppableId` must stay STABLE while mounted (anchored to a formId, + * never a positional index): @hello-pangea/dnd does not support changing + * ids on mounted droppables/draggables, which silently breaks them. + * - `type="RECIPIENT"` already scopes it to recipient-row drags, and + * `isDropDisabled` must not be toggled based on the active drag, as + * @hello-pangea/dnd snapshots it at drag start (before state updates land). + * - It must keep a CONSTANT size: droppable geometry is captured when a drag + * starts, so resizing during the drag would leave the visible strip and the + * actual hit area in different places. Only colors may change mid-drag. + */ +const RecipientStepGap = ({ droppableId }: { droppableId: string }) => ( + + {(provided, snapshot) => ( +
+
+ {provided.placeholder} +
+ )} + +); + +export const RecipientStepCard = ({ + stepIndex, + step, + isLastStep, + draggableProvided, + draggableSnapshot, + draggingType, + isGroupingEnabled, + isStepLocked, + isRemoveDisabled, + flatIndexByFormId, + canSignerBeModified, + isSubmitting, + onUngroup, + rowProps, +}: RecipientStepCardProps) => { + const isGroup = step.members.length > 1; + const isCombineTarget = draggingType === 'STEP' && Boolean(draggableSnapshot.combineTargetFor); + + // All droppable ids are anchored to the first member's formId (never a + // positional index) so they stay stable while cards are reordered — + // @hello-pangea/dnd does not support changing ids on mounted elements. + const stepAnchor = step.members[0].formId; + + return ( +
+ + + + {(droppableProvided, droppableSnapshot) => { + const isJoinTarget = draggingType === 'RECIPIENT' && droppableSnapshot.isDraggingOver; + const isHighlighted = isCombineTarget || isJoinTarget; + + return ( +
+ {isHighlighted && ( + + + Release to group + + )} + +
+ + + + + + Group {step.order} + + + {isGroup && ( + <> + + + {step.members.length} recipients · any order + + + + + )} +
+ + {step.members.map((member, memberIndex) => { + const signerIndex = flatIndexByFormId.get(member.formId) ?? -1; + const canBeModified = canSignerBeModified(member); + + return ( + + {(memberProvided, memberSnapshot) => ( +
+ +
+ )} +
+ ); + })} + + {droppableProvided.placeholder} +
+ ); + }} +
+ + {isLastStep && } +
+ ); +}; diff --git a/apps/remix/app/components/general/envelope-editor/recipient-step-list.tsx b/apps/remix/app/components/general/envelope-editor/recipient-step-list.tsx new file mode 100644 index 000000000..57d57201f --- /dev/null +++ b/apps/remix/app/components/general/envelope-editor/recipient-step-list.tsx @@ -0,0 +1,388 @@ +import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value'; +import { + type TEditorRecipientsFormSchema, + updateEditorSigners, +} from '@documenso/lib/client-only/hooks/use-editor-recipients'; +import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider'; +import { + extractRecipientToNewStep, + getLastLockedStepIndex, + groupRecipientsBySigningOrder, + mergeSteps, + moveRecipientToStep, + normalizeGroupedSigningOrders, + reorderStep, + ungroupStep, +} from '@documenso/lib/utils/recipient-groups'; +import { canEditorRecipientBeModified, isAssistantLastSigner } from '@documenso/lib/utils/recipients'; +import { trpc } from '@documenso/trpc/react'; +import type { RecipientAutoCompleteOption } from '@documenso/ui/components/recipient/recipient-autocomplete-input'; +import { Badge } from '@documenso/ui/primitives/badge'; +import { useToast } from '@documenso/ui/primitives/use-toast'; +import type { BeforeCapture, DropResult } from '@hello-pangea/dnd'; +import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd'; +import { Trans, useLingui } from '@lingui/react/macro'; +import { DocumentSigningOrder, RecipientRole } from '@prisma/client'; +import { useCallback, useMemo, useState } from 'react'; +import { RecipientRow } from './recipient-row'; +import { type DraggingType, RecipientStepCard } from './recipient-step-card'; + +type TEditorSigner = TEditorRecipientsFormSchema['signers'][number]; + +export type RecipientStepListProps = { + showAdvancedSettings: boolean; +}; + +export const RecipientStepList = ({ showAdvancedSettings }: RecipientStepListProps) => { + const { t } = useLingui(); + const { toast } = useToast(); + + const { envelope, editorRecipients, isEmbedded, isCscMode } = useCurrentEnvelopeEditor(); + const { form } = editorRecipients; + + // Signing groups are an SES feature: TSP (AES/QES) signatures must be + // strictly sequential, so on CSC instances the group affordances (card + // combine, row-to-card join) are disabled while step reordering and + // ungrouping of invalid API-created state stay available. + const isGroupingEnabled = !isCscMode; + + const [draggingType, setDraggingType] = useState(null); + const [recipientSearchQuery, setRecipientSearchQuery] = useState(''); + + const debouncedRecipientSearchQuery = useDebouncedValue(recipientSearchQuery, 500); + + const { data: recipientSuggestionsData, isLoading } = trpc.recipient.suggestions.find.useQuery( + { + query: debouncedRecipientSearchQuery, + }, + { + enabled: debouncedRecipientSearchQuery.length > 1 && !isEmbedded, + retry: false, + }, + ); + + const recipientSuggestions = recipientSuggestionsData?.results || []; + + const watchedSigners = form.watch('signers'); + const isSequential = form.watch('signingOrder') === DocumentSigningOrder.SEQUENTIAL; + const { isSubmitting } = form.formState; + + const { steps, ccRecipients } = useMemo(() => groupRecipientsBySigningOrder(watchedSigners), [watchedSigners]); + + // Signing is sequential, so anyone who has already acted is at or before the + // current step. Those steps hold persisted orders that cannot be rewritten, + // so ordering is locked up to and including the last of them; everything + // after can still be rearranged freely. + const lastLockedStepIndex = useMemo( + () => getLastLockedStepIndex(steps, (signer) => canEditorRecipientBeModified(envelope, signer.id)), + [steps, envelope], + ); + + const isRemoveDisabled = watchedSigners.length === 1; + + const flatIndexByFormId = useMemo( + () => new Map(watchedSigners.map((signer, index) => [signer.formId, index])), + [watchedSigners], + ); + + const canSignerBeModified = useCallback( + (signer: TEditorSigner) => canEditorRecipientBeModified(envelope, signer.id), + [envelope], + ); + + const applySigners = useCallback( + (updatedSigners: TEditorSigner[], options: { warnWhenAssistantLast?: boolean } = {}) => { + const { warnWhenAssistantLast = true } = options; + + updateEditorSigners(form, updatedSigners); + + if (warnWhenAssistantLast && 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.`, + }); + } + + void form.trigger('signers'); + }, + [form, t, toast], + ); + + const handleRoleChange = useCallback( + (signerIndex: number, role: RecipientRole) => { + const currentSigners = form.getValues('signers'); + const signingOrder = form.getValues('signingOrder'); + + 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 = normalizeGroupedSigningOrders( + currentSigners.map((signer, index) => ({ + ...signer, + role: index === signerIndex ? role : signer.role, + })), + canSignerBeModified, + ); + + applySigners(updatedSigners, { warnWhenAssistantLast: role === RecipientRole.ASSISTANT }); + }, + [form, toast, t, canSignerBeModified, applySigners], + ); + + const handleRemove = useCallback( + (signerIndex: number) => { + const signer = form.getValues('signers')[signerIndex]; + + if (!signer) { + return; + } + + if (!canSignerBeModified(signer)) { + toast({ + title: t`Cannot remove signer`, + description: t`This signer has already signed the document.`, + variant: 'destructive', + }); + + return; + } + + const updatedSigners = normalizeGroupedSigningOrders( + form.getValues('signers').filter((s) => s.formId !== signer.formId), + canSignerBeModified, + ); + + applySigners(updatedSigners, { warnWhenAssistantLast: false }); + }, + [form, toast, t, canSignerBeModified, applySigners], + ); + + const handleUngroup = useCallback( + (stepIndex: number) => { + applySigners(ungroupStep(form.getValues('signers'), stepIndex, canSignerBeModified)); + }, + [form, canSignerBeModified, applySigners], + ); + + const handleAutoCompleteSelect = useCallback( + (signerIndex: number, suggestion: RecipientAutoCompleteOption) => { + form.setValue(`signers.${signerIndex}.email`, suggestion.email, { + shouldValidate: true, + shouldDirty: true, + }); + form.setValue(`signers.${signerIndex}.name`, suggestion.name || '', { + shouldValidate: true, + shouldDirty: true, + }); + }, + [form], + ); + + const onBeforeCapture = useCallback((before: BeforeCapture) => { + setDraggingType(before.draggableId.startsWith('step-') ? 'STEP' : 'RECIPIENT'); + }, []); + + const onDragEnd = useCallback( + (result: DropResult) => { + setDraggingType(null); + + const currentSigners = form.getValues('signers'); + + // Drag-and-drop ids are anchored to the first member's formId so they + // stay stable across reorders; resolve them back to step indexes here. + const { steps: currentSteps } = groupRecipientsBySigningOrder(currentSigners); + + const findStepIndexByAnchor = (anchorFormId: string) => + currentSteps.findIndex((step) => step.members[0]?.formId === anchorFormId); + + if (result.type === 'STEP') { + if (result.combine) { + // Unreachable while combining is disabled, but kept as a guard so a + // stray combine result can never form a group on a CSC envelope. + if (!isGroupingEnabled) { + return; + } + + const targetStepIndex = findStepIndexByAnchor(result.combine.draggableId.slice('step-'.length)); + + if (targetStepIndex === -1) { + return; + } + + applySigners(mergeSteps(currentSigners, result.source.index, targetStepIndex, canSignerBeModified)); + + return; + } + + if (result.destination) { + applySigners(reorderStep(currentSigners, result.source.index, result.destination.index, canSignerBeModified)); + } + + return; + } + + if (result.type === 'RECIPIENT' && result.destination) { + const formId = result.draggableId.slice('recipient-'.length); + const { droppableId } = result.destination; + + if (droppableId === 'gap-end') { + applySigners(extractRecipientToNewStep(currentSigners, formId, currentSteps.length, canSignerBeModified)); + + return; + } + + if (droppableId.startsWith('gap-')) { + const insertStepIndex = findStepIndexByAnchor(droppableId.slice('gap-'.length)); + + if (insertStepIndex === -1) { + return; + } + + applySigners(extractRecipientToNewStep(currentSigners, formId, insertStepIndex, canSignerBeModified)); + + return; + } + + if (droppableId.startsWith('step-members-')) { + // Unreachable while the card drop-zones are disabled, but kept as a + // guard so a stray drop can never form a group on a CSC envelope. + if (!isGroupingEnabled) { + return; + } + + const targetStepIndex = findStepIndexByAnchor(droppableId.slice('step-members-'.length)); + + if (targetStepIndex === -1) { + return; + } + + applySigners(moveRecipientToStep(currentSigners, formId, targetStepIndex, canSignerBeModified)); + } + } + }, + [form, canSignerBeModified, applySigners, isGroupingEnabled], + ); + + const sharedRowProps = { + showAdvancedSettings, + recipientSuggestions, + isLoadingSuggestions: isLoading, + onRoleChange: handleRoleChange, + onRemove: handleRemove, + onAutoCompleteSelect: handleAutoCompleteSelect, + onSearchQueryChange: setRecipientSearchQuery, + }; + + return ( +
+ {!showAdvancedSettings && !isSequential && ( +
+ + Email + + + Name + + +
+ )} + + {!isSequential ? ( +
+ {watchedSigners.map((signer, index) => ( + + ))} +
+ ) : ( + <> + + + {(provided) => ( +
+ {steps.map((step, stepIndex) => { + const isStepLocked = stepIndex <= lastLockedStepIndex; + + return ( + + {(draggableProvided, draggableSnapshot) => ( + + )} + + ); + })} + + {provided.placeholder} +
+ )} +
+
+ + {ccRecipients.length > 0 && ( +
+ + Receives Copy + + + {ccRecipients.map((signer) => ( +
+ +
+ ))} +
+ )} + + )} +
+ ); +}; diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.legacy_editor.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.legacy_editor.tsx index 7baf38f9b..bf2c757c8 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.legacy_editor.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.legacy_editor.tsx @@ -43,6 +43,10 @@ export async function loader({ params, request }: Route.LoaderArgs) { throw new Response('Not Found', { status: 404 }); } + if (document.internalVersion !== 1) { + throw redirect(`${documentRootPath}/${document.envelopeId}/edit`); + } + const documentVisibility = document.visibility; const currentTeamMemberRole = team.currentTeamRole; const isRecipient = document.recipients.find((recipient) => recipient.email === user.email); diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id.legacy_editor.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id.legacy_editor.tsx index 6e8607cc7..87737ee0e 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id.legacy_editor.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id.legacy_editor.tsx @@ -42,6 +42,10 @@ export async function loader({ params, request }: Route.LoaderArgs) { throw redirect(templateRootPath); } + if (template.internalVersion !== 1) { + throw redirect(`${templateRootPath}/${template.envelopeId}/edit`); + } + return superLoaderJson({ template: { ...template, diff --git a/apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx b/apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx index d35313fd8..49f847532 100644 --- a/apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx +++ b/apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx @@ -93,22 +93,23 @@ const handleV1Loader = async ({ params, request }: Route.LoaderArgs) => { }) : [recipient]; - if ( - document.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL && - recipient.role !== RecipientRole.ASSISTANT - ) { - const nextPendingRecipient = await getNextPendingRecipient({ - documentId: document.id, - currentRecipientId: recipient.id, - }); + // Dictation eligibility must be decided here, over the FULL recipient list + // — the same computation the completion route enforces. `allRecipients` is + // role-scoped (assistants only see strictly later steps, not their own + // group peers), so deriving it client-side from that list would offer + // dictation the server then silently ignores. + const nextPendingRecipient = + document.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL + ? await getNextPendingRecipient({ + documentId: document.id, + currentRecipientId: recipient.id, + }) + : null; - if (nextPendingRecipient) { - allRecipients.push({ - ...nextPendingRecipient, - fields: [], - }); - } - } + // Only the identity is needed client-side (dictation flag + prefill). + const nextRecipient = nextPendingRecipient + ? { name: nextPendingRecipient.name, email: nextPendingRecipient.email } + : null; const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({ documentAuth: document.authOptions, @@ -170,6 +171,7 @@ const handleV1Loader = async ({ params, request }: Route.LoaderArgs) => { recipient, recipientWithFields, allRecipients, + nextRecipient, completedFields, recipientSignature, isRecipientsTurn, @@ -414,6 +416,7 @@ const SigningPageV1 = ({ data }: { data: Awaited diff --git a/packages/api/v1/schema.ts b/packages/api/v1/schema.ts index 81d969f2f..a4296731e 100644 --- a/packages/api/v1/schema.ts +++ b/packages/api/v1/schema.ts @@ -11,6 +11,7 @@ import { import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email'; import { ZEnvelopeAttachmentTypeSchema } from '@documenso/lib/types/envelope-attachment'; import { ZFieldMetaPrefillFieldsSchema, ZFieldMetaSchema } from '@documenso/lib/types/field-meta'; +import { ZRecipientSigningOrderSchema } from '@documenso/lib/types/recipient'; import { zEmail } from '@documenso/lib/utils/zod'; import { DocumentDataType, @@ -145,7 +146,7 @@ export const ZCreateDocumentMutationSchema = z.object({ name: z.string().min(1), email: zEmail().min(1), role: z.nativeEnum(RecipientRole).optional().default(RecipientRole.SIGNER), - signingOrder: z.number().nullish(), + signingOrder: ZRecipientSigningOrderSchema.nullish(), }), ), meta: z @@ -235,7 +236,7 @@ export const ZCreateDocumentFromTemplateMutationSchema = z.object({ name: z.string().min(1), email: zEmail().min(1), role: z.nativeEnum(RecipientRole).optional().default(RecipientRole.SIGNER), - signingOrder: z.number().nullish(), + signingOrder: ZRecipientSigningOrderSchema.nullish(), }), ), meta: z @@ -315,7 +316,7 @@ export const ZGenerateDocumentFromTemplateMutationSchema = z.object({ id: z.number(), email: zEmail(), name: z.string().optional(), - signingOrder: z.number().optional(), + signingOrder: ZRecipientSigningOrderSchema.optional(), }), ) .refine( @@ -389,7 +390,7 @@ export const ZCreateRecipientMutationSchema = z.object({ name: z.string().min(1), email: zEmail().min(1), role: z.nativeEnum(RecipientRole).optional().default(RecipientRole.SIGNER), - signingOrder: z.number().nullish(), + signingOrder: ZRecipientSigningOrderSchema.nullish(), authOptions: z .object({ actionAuth: z diff --git a/packages/app-tests/e2e/api/trpc/envelope/expired-recipient-field-signing.spec.ts b/packages/app-tests/e2e/api/trpc/envelope/expired-recipient-field-signing.spec.ts new file mode 100644 index 000000000..2ae53a0ea --- /dev/null +++ b/packages/app-tests/e2e/api/trpc/envelope/expired-recipient-field-signing.spec.ts @@ -0,0 +1,110 @@ +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { prisma } from '@documenso/prisma'; +import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents'; +import { seedUser } from '@documenso/prisma/seed/users'; +import type { Page } from '@playwright/test'; +import { expect, test } from '@playwright/test'; +import { FieldType } from '@prisma/client'; + +const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL(); + +/** + * Field insertion must respect the recipient's signing window: an expired + * recipient can no longer act on the envelope at all. The V1 endpoints assert + * this; the V2 `envelope.field.sign` route historically did not. + */ + +const callSignEnvelopeField = async (page: Page, input: { token: string; fieldId: number; value: string }) => { + return await page.context().request.post(`${WEBAPP_BASE_URL}/api/trpc/envelope.field.sign`, { + headers: { 'content-type': 'application/json' }, + data: JSON.stringify({ + json: { + token: input.token, + fieldId: input.fieldId, + fieldValue: { + type: FieldType.TEXT, + value: input.value, + }, + }, + }), + }); +}; + +const seedV2PendingDocumentWithTextField = async () => { + const { user, team } = await seedUser(); + const { user: signer } = await seedUser(); + + const { recipients } = await seedPendingDocumentWithFullFields({ + owner: user, + teamId: team.id, + recipients: [signer], + fields: [FieldType.TEXT], + updateDocumentOptions: { + internalVersion: 2, + }, + }); + + const recipient = recipients[0]; + const textField = recipient.fields.find((field) => field.type === FieldType.TEXT); + + if (!textField) { + throw new Error('Seeded text field not found'); + } + + return { recipient, textField }; +}; + +test('[ENVELOPE_FIELD_SIGN]: rejects field insertion for an expired recipient', async ({ page }) => { + const { recipient, textField } = await seedV2PendingDocumentWithTextField(); + + await prisma.recipient.update({ + where: { id: recipient.id }, + data: { + // Expired one hour ago. + expiresAt: new Date(Date.now() - 60 * 60 * 1000), + }, + }); + + // The seed pre-populates customText with a placeholder value. + const fieldBefore = await prisma.field.findUniqueOrThrow({ where: { id: textField.id } }); + + const response = await callSignEnvelopeField(page, { + token: recipient.token, + fieldId: textField.id, + value: 'TEXT', + }); + + expect(response.ok()).toBeFalsy(); + + const fieldAfter = await prisma.field.findUniqueOrThrow({ where: { id: textField.id } }); + + expect(fieldAfter.inserted).toBe(false); + expect(fieldAfter.customText).toBe(fieldBefore.customText); +}); + +test('[ENVELOPE_FIELD_SIGN]: accepts field insertion for a recipient within their signing window', async ({ page }) => { + // Positive control: proves the request format reaches the route, so the + // expired-recipient rejection above cannot pass vacuously. + const { recipient, textField } = await seedV2PendingDocumentWithTextField(); + + await prisma.recipient.update({ + where: { id: recipient.id }, + data: { + // Expires an hour from now. + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + }, + }); + + const response = await callSignEnvelopeField(page, { + token: recipient.token, + fieldId: textField.id, + value: 'TEXT', + }); + + expect(response.ok()).toBeTruthy(); + + const fieldAfter = await prisma.field.findUniqueOrThrow({ where: { id: textField.id } }); + + expect(fieldAfter.inserted).toBe(true); + expect(fieldAfter.customText).toBe('TEXT'); +}); diff --git a/packages/app-tests/e2e/api/v2/recipient-signing-order-validation.spec.ts b/packages/app-tests/e2e/api/v2/recipient-signing-order-validation.spec.ts new file mode 100644 index 000000000..a0e342d62 --- /dev/null +++ b/packages/app-tests/e2e/api/v2/recipient-signing-order-validation.spec.ts @@ -0,0 +1,82 @@ +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { prisma } from '@documenso/prisma'; +import { type APIRequestContext, expect, test } from '@playwright/test'; + +import { apiSeedDraftDocument } from '../../fixtures/api-seeds'; + +const API_BASE_URL = `${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta`; + +/** + * `Recipient.signingOrder` is an Int column, but nothing constrained the input + * to an integer. Prisma does not reject a fraction — it truncates it (1.5 -> 1), + * so distinct orders could silently collapse onto the same value, which under + * signing groups means "same step". Zero and negatives were persisted as-is and + * sort ahead of everything, including the `?? 0` fallback in assistant scoping. + */ + +const createRecipient = async (request: APIRequestContext, token: string, envelopeId: string, signingOrder: number) => + await request.post(`${API_BASE_URL}/envelope/recipient/create-many`, { + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + data: { + envelopeId, + data: [ + { + email: `signing-order-${Date.now()}-${signingOrder}@documenso.com`, + name: 'Signing Order Test', + role: 'SIGNER', + signingOrder, + }, + ], + }, + }); + +test('[SIGNING_ORDER_VALIDATION]: rejects a fractional signing order with a client error', async ({ request }) => { + const { envelope, token } = await apiSeedDraftDocument(request, { title: '[TEST] Signing order validation' }); + + const response = await createRecipient(request, token, envelope.id, 1.5); + + expect(response.status()).toBe(400); + + // Nothing may be written — in particular not a silently truncated `1`. + const recipients = await prisma.recipient.findMany({ where: { envelopeId: envelope.id } }); + + expect(recipients).toHaveLength(0); +}); + +test('[SIGNING_ORDER_VALIDATION]: rejects a zero signing order', async ({ request }) => { + const { envelope, token } = await apiSeedDraftDocument(request, { title: '[TEST] Signing order validation zero' }); + + const response = await createRecipient(request, token, envelope.id, 0); + + expect(response.status()).toBe(400); + + const persisted = await prisma.recipient.findMany({ where: { envelopeId: envelope.id, signingOrder: 0 } }); + + expect(persisted).toHaveLength(0); +}); + +test('[SIGNING_ORDER_VALIDATION]: rejects a negative signing order', async ({ request }) => { + const { envelope, token } = await apiSeedDraftDocument(request, { + title: '[TEST] Signing order validation negative', + }); + + const response = await createRecipient(request, token, envelope.id, -1); + + expect(response.status()).toBe(400); + + const persisted = await prisma.recipient.findMany({ where: { envelopeId: envelope.id, signingOrder: -1 } }); + + expect(persisted).toHaveLength(0); +}); + +test('[SIGNING_ORDER_VALIDATION]: still accepts a valid positive integer signing order', async ({ request }) => { + const { envelope, token } = await apiSeedDraftDocument(request, { title: '[TEST] Signing order validation valid' }); + + const response = await createRecipient(request, token, envelope.id, 2); + + expect(response.ok(), await response.text()).toBeTruthy(); + + const persisted = await prisma.recipient.findMany({ where: { envelopeId: envelope.id, signingOrder: 2 } }); + + expect(persisted).toHaveLength(1); +}); diff --git a/packages/app-tests/e2e/api/v2/tsp-recipient-grouping.spec.ts b/packages/app-tests/e2e/api/v2/tsp-recipient-grouping.spec.ts new file mode 100644 index 000000000..b1dfae0b8 --- /dev/null +++ b/packages/app-tests/e2e/api/v2/tsp-recipient-grouping.spec.ts @@ -0,0 +1,111 @@ +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { prisma } from '@documenso/prisma'; +import { type APIRequestContext, expect, test } from '@playwright/test'; + +import { apiSeedDraftDocument } from '../../fixtures/api-seeds'; + +const API_BASE_URL = `${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta`; + +/** + * AES/QES envelopes cannot contain signing groups: two recipients sharing a + * step sign in parallel, which breaks the per-recipient /ByteRange invariant + * TSP signatures depend on. That rule previously lived only in the editor's + * form schema, so the API would happily create the forbidden state. + * + * The signature level is seeded directly because `resolveSignatureLevel` + * coerces AES/QES down to SES on a non-CSC instance, so it cannot be requested + * through the API here. + */ + +const createRecipients = async ( + request: APIRequestContext, + token: string, + envelopeId: string, + recipients: Array<{ signingOrder?: number }>, +) => + await request.post(`${API_BASE_URL}/envelope/recipient/create-many`, { + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + data: { + envelopeId, + data: recipients.map((recipient, index) => ({ + email: `tsp-grouping-${Date.now()}-${index}@documenso.com`, + name: `TSP Recipient ${index}`, + role: 'SIGNER', + ...recipient, + })), + }, + }); + +const seedEnvelopeAtSignatureLevel = async (request: APIRequestContext, signatureLevel: string) => { + const { envelope, token } = await apiSeedDraftDocument(request, { title: `[TEST] ${signatureLevel} grouping` }); + + await prisma.envelope.update({ where: { id: envelope.id }, data: { signatureLevel } }); + + return { envelopeId: envelope.id, token }; +}; + +test('[TSP_GROUPING]: rejects two recipients sharing a signing order on an AES envelope', async ({ request }) => { + const { envelopeId, token } = await seedEnvelopeAtSignatureLevel(request, 'AES'); + + const response = await createRecipients(request, token, envelopeId, [{ signingOrder: 1 }, { signingOrder: 1 }]); + + expect(response.status()).toBe(400); + + const recipients = await prisma.recipient.findMany({ where: { envelopeId } }); + + expect(recipients).toHaveLength(0); +}); + +test('[TSP_GROUPING]: rejects a second recipient joining an existing step on an AES envelope', async ({ request }) => { + const { envelopeId, token } = await seedEnvelopeAtSignatureLevel(request, 'AES'); + + const first = await createRecipients(request, token, envelopeId, [{ signingOrder: 1 }]); + + expect(first.ok(), await first.text()).toBeTruthy(); + + // The payload alone looks fine — only the resulting set reveals the group. + const second = await createRecipients(request, token, envelopeId, [{ signingOrder: 1 }]); + + expect(second.status()).toBe(400); + + const recipients = await prisma.recipient.findMany({ where: { envelopeId } }); + + expect(recipients).toHaveLength(1); +}); + +test('[TSP_GROUPING]: rejects two recipients without a signing order on a QES envelope', async ({ request }) => { + const { envelopeId, token } = await seedEnvelopeAtSignatureLevel(request, 'QES'); + + // Both land in the same tail step, so they would sign in parallel. + const response = await createRecipients(request, token, envelopeId, [{}, {}]); + + expect(response.status()).toBe(400); + + const recipients = await prisma.recipient.findMany({ where: { envelopeId } }); + + expect(recipients).toHaveLength(0); +}); + +test('[TSP_GROUPING]: accepts distinct signing orders on an AES envelope', async ({ request }) => { + const { envelopeId, token } = await seedEnvelopeAtSignatureLevel(request, 'AES'); + + const response = await createRecipients(request, token, envelopeId, [{ signingOrder: 1 }, { signingOrder: 2 }]); + + expect(response.ok(), await response.text()).toBeTruthy(); + + const recipients = await prisma.recipient.findMany({ where: { envelopeId } }); + + expect(recipients).toHaveLength(2); +}); + +test('[TSP_GROUPING]: still allows signing groups on an SES envelope', async ({ request }) => { + const { envelopeId, token } = await seedEnvelopeAtSignatureLevel(request, 'SES'); + + const response = await createRecipients(request, token, envelopeId, [{ signingOrder: 1 }, { signingOrder: 1 }]); + + expect(response.ok(), await response.text()).toBeTruthy(); + + const recipients = await prisma.recipient.findMany({ where: { envelopeId } }); + + expect(recipients.map((recipient) => recipient.signingOrder)).toEqual([1, 1]); +}); diff --git a/packages/app-tests/e2e/document-auth/assistant-grouped-dictation-ui.spec.ts b/packages/app-tests/e2e/document-auth/assistant-grouped-dictation-ui.spec.ts new file mode 100644 index 000000000..3d7c8e4dc --- /dev/null +++ b/packages/app-tests/e2e/document-auth/assistant-grouped-dictation-ui.spec.ts @@ -0,0 +1,116 @@ +import { prisma } from '@documenso/prisma'; +import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents'; +import { seedUser } from '@documenso/prisma/seed/users'; +import { expect, test } from '@playwright/test'; +import { DocumentSigningOrder, FieldType, RecipientRole, SigningStatus } from '@prisma/client'; + +import { signDirectSignaturePad } from '../fixtures/signature'; + +/** + * An assistant sharing a signing step with an unsigned peer cannot dictate + * the next signer: the flow does not advance until the whole step completes, + * so the server ignores any dictated identity. The signing page must + * therefore not OFFER dictation in that state — historically it did, because + * the assistant's recipient list excludes their own group peers, and the + * client derived dictation eligibility from that truncated list while the + * server decided from the full one. + */ +test('[NEXT_RECIPIENT_DICTATION]: assistant with an unsigned group peer is not offered dictation', async ({ page }) => { + const { user, team } = await seedUser(); + const { user: assistant } = await seedUser(); + const { user: peerSigner } = await seedUser(); + const { user: lastSigner } = await seedUser(); + + const { recipients, document } = await seedPendingDocumentWithFullFields({ + owner: user, + teamId: team.id, + recipients: [assistant, peerSigner, lastSigner], + recipientsCreateOptions: [ + // The assistant shares step 1 with an unsigned peer; step 2 holds a + // single recipient — the exact shape where dictation looks available + // from the assistant's truncated recipient list. + { signingOrder: 1, role: RecipientRole.ASSISTANT }, + { signingOrder: 1, role: RecipientRole.SIGNER }, + { signingOrder: 2, role: RecipientRole.SIGNER }, + ], + updateDocumentOptions: { + documentMeta: { + upsert: { + create: { + signingOrder: DocumentSigningOrder.SEQUENTIAL, + allowDictateNextSigner: true, + }, + update: { + signingOrder: DocumentSigningOrder.SEQUENTIAL, + allowDictateNextSigner: true, + }, + }, + }, + }, + }); + + const assistantRecipient = recipients[0]; + const lastRecipient = recipients[2]; + + const signUrl = `/sign/${assistantRecipient.token}`; + + await page.goto(signUrl); + await expect(page.getByRole('heading', { name: 'Assist Document' })).toBeVisible(); + + await page.waitForTimeout(1000); + + await page.getByRole('radio', { name: assistantRecipient.name }).click(); + + // Fill in the assistant's own fields. + for (const field of assistantRecipient.fields) { + await page.locator(`#field-${field.id}`).getByRole('button').click(); + + if (field.type === FieldType.SIGNATURE) { + await signDirectSignaturePad(page); + await page.getByRole('button', { name: 'Sign', exact: true }).click(); + } + + if (field.type === FieldType.TEXT) { + await page.locator('#custom-text').fill('TEXT'); + await page.getByRole('button', { name: 'Save' }).click(); + } + + await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true'); + } + + await page.getByRole('button', { name: 'Continue' }).click(); + + const dialog = page.getByRole('dialog'); + + await expect(dialog).toBeVisible(); + + // The unsigned peer blocks advancement, so dictation must not be offered. + await expect(dialog.getByText('The next recipient to sign this document will be')).not.toBeVisible(); + await expect(dialog.getByRole('button', { name: 'Update Recipient' })).not.toBeVisible(); + + // Later recipients' fields are still uninserted, so the confirm button + // reads "Proceed" rather than "Continue". + await dialog.getByRole('button', { name: /Continue|Proceed/ }).click(); + await page.waitForURL(`${signUrl}/complete`); + + // The assistant completed; nobody was renamed and the flow did not advance + // past the unsigned peer. + await expect + .poll(async () => { + const assistantAfter = await prisma.recipient.findUniqueOrThrow({ + where: { id: assistantRecipient.id }, + }); + + return assistantAfter.signingStatus; + }) + .toBe(SigningStatus.SIGNED); + + const lastAfter = await prisma.recipient.findUniqueOrThrow({ where: { id: lastRecipient.id } }); + + expect(lastAfter.name).toBe(lastRecipient.name); + expect(lastAfter.email).toBe(lastRecipient.email); + + const envelope = await prisma.envelope.findUniqueOrThrow({ where: { id: document.id } }); + + expect(envelope.status).toBe('PENDING'); +}); diff --git a/packages/app-tests/e2e/document-auth/assistant-null-order.spec.ts b/packages/app-tests/e2e/document-auth/assistant-null-order.spec.ts new file mode 100644 index 000000000..cc8f99d99 --- /dev/null +++ b/packages/app-tests/e2e/document-auth/assistant-null-order.spec.ts @@ -0,0 +1,186 @@ +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token'; +import { signFieldWithToken } from '@documenso/lib/server-only/field/sign-field-with-token'; +import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/get-recipients-for-assistant'; +import { prisma } from '@documenso/prisma'; +import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents'; +import { seedUser } from '@documenso/prisma/seed/users'; +import type { Page } from '@playwright/test'; +import { expect, test } from '@playwright/test'; +import { DocumentSigningOrder, FieldType, RecipientRole } from '@prisma/client'; + +const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL(); + +/** + * A recipient without a signing order sits in the LAST step — the convention + * `effectiveOrder` encodes and the server sorts by (NULLS LAST). The + * assistant scoping filters must agree with it: + * + * - an ordered assistant may assist a null-order recipient (they are in the + * strictly later tail step), and + * - a null-order assistant may assist NOBODY (nobody comes after the last + * step) — historically `signingOrder ?? 0` treated them as FIRST, letting + * their token prefill every ordered recipient's fields. + * + * Null orders are only produced via the API, which is why no editor-driven + * test covers this. + */ + +const seedAssistantDocument = async (options: { + assistantOrder: number | null; + signerOrder: number | null; + internalVersion?: number; +}) => { + const { user, team } = await seedUser(); + const { user: assistantUser } = await seedUser(); + const { user: signerUser } = await seedUser(); + + const { recipients } = await seedPendingDocumentWithFullFields({ + owner: user, + teamId: team.id, + recipients: [assistantUser, signerUser], + recipientsCreateOptions: [ + { signingOrder: options.assistantOrder, role: RecipientRole.ASSISTANT }, + { signingOrder: options.signerOrder, role: RecipientRole.SIGNER }, + ], + fields: [FieldType.TEXT], + updateDocumentOptions: { + internalVersion: options.internalVersion ?? 1, + documentMeta: { + upsert: { + create: { signingOrder: DocumentSigningOrder.SEQUENTIAL }, + update: { signingOrder: DocumentSigningOrder.SEQUENTIAL }, + }, + }, + }, + }); + + // The seed returns recipients ordered by signingOrder (nulls last), so + // positional destructuring would swap roles — select by role instead. + const assistant = recipients.find((recipient) => recipient.role === RecipientRole.ASSISTANT); + const signer = recipients.find((recipient) => recipient.role === RecipientRole.SIGNER); + + if (!assistant || !signer) { + throw new Error('Seeded recipients not found'); + } + + const signerTextField = signer.fields.find((field) => field.type === FieldType.TEXT); + + if (!signerTextField) { + throw new Error('Seeded text field not found'); + } + + return { assistant, signer, signerTextField }; +}; + +const callSignEnvelopeField = async (page: Page, input: { token: string; fieldId: number }) => { + return await page.context().request.post(`${WEBAPP_BASE_URL}/api/trpc/envelope.field.sign`, { + headers: { 'content-type': 'application/json' }, + data: JSON.stringify({ + json: { + token: input.token, + fieldId: input.fieldId, + fieldValue: { + type: FieldType.TEXT, + value: 'TEXT', + }, + }, + }), + }); +}; + +test('[ASSISTANT_NULL_ORDER]: an ordered assistant can assist a null-order (tail-step) recipient', async () => { + const { assistant, signer, signerTextField } = await seedAssistantDocument({ + assistantOrder: 1, + signerOrder: null, + }); + + // The tail-step recipient is strictly later, so they must be assistable. + const assistableRecipients = await getRecipientsForAssistant({ token: assistant.token }); + + expect(assistableRecipients.map((recipient) => recipient.id)).toContain(signer.id); + + // Their non-signature fields must be visible to the assistant. + const fields = await getFieldsForToken({ token: assistant.token }); + + expect(fields.map((field) => field.id)).toContain(signerTextField.id); + + // And prefillable. + await signFieldWithToken({ + token: assistant.token, + fieldId: signerTextField.id, + value: 'TEXT', + }); + + const fieldAfter = await prisma.field.findUniqueOrThrow({ where: { id: signerTextField.id } }); + + expect(fieldAfter.inserted).toBe(true); +}); + +test('[ASSISTANT_NULL_ORDER]: a null-order (tail-step) assistant cannot assist anyone', async () => { + const { assistant, signer, signerTextField } = await seedAssistantDocument({ + assistantOrder: null, + signerOrder: 1, + }); + + // The null-order assistant sits in the last step: nobody comes after them. + const assistableRecipients = await getRecipientsForAssistant({ token: assistant.token }); + + expect(assistableRecipients.map((recipient) => recipient.id)).toEqual([assistant.id]); + + // Every ordered recipient is in an EARLIER step — prefilling must fail. + await expect( + signFieldWithToken({ + token: assistant.token, + fieldId: signerTextField.id, + value: 'TEXT', + }), + ).rejects.toThrow(); + + const fieldAfter = await prisma.field.findUniqueOrThrow({ where: { id: signerTextField.id } }); + + expect(fieldAfter.inserted).toBe(false); + expect(fieldAfter.id).not.toBe(signer.id); // sanity: distinct entities +}); + +test('[ASSISTANT_NULL_ORDER]: V2 route allows an ordered assistant to prefill a null-order recipient', async ({ + page, +}) => { + const { assistant, signerTextField } = await seedAssistantDocument({ + assistantOrder: 1, + signerOrder: null, + internalVersion: 2, + }); + + const response = await callSignEnvelopeField(page, { + token: assistant.token, + fieldId: signerTextField.id, + }); + + expect(response.ok()).toBeTruthy(); + + const fieldAfter = await prisma.field.findUniqueOrThrow({ where: { id: signerTextField.id } }); + + expect(fieldAfter.inserted).toBe(true); +}); + +test('[ASSISTANT_NULL_ORDER]: V2 route rejects a null-order assistant prefilling an ordered recipient', async ({ + page, +}) => { + const { assistant, signerTextField } = await seedAssistantDocument({ + assistantOrder: null, + signerOrder: 1, + internalVersion: 2, + }); + + const response = await callSignEnvelopeField(page, { + token: assistant.token, + fieldId: signerTextField.id, + }); + + expect(response.ok()).toBeFalsy(); + + const fieldAfter = await prisma.field.findUniqueOrThrow({ where: { id: signerTextField.id } }); + + expect(fieldAfter.inserted).toBe(false); +}); diff --git a/packages/app-tests/e2e/document-auth/assistant-signing-groups.spec.ts b/packages/app-tests/e2e/document-auth/assistant-signing-groups.spec.ts new file mode 100644 index 000000000..0bb1f732d --- /dev/null +++ b/packages/app-tests/e2e/document-auth/assistant-signing-groups.spec.ts @@ -0,0 +1,266 @@ +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token'; +import { prisma } from '@documenso/prisma'; +import { type APIRequestContext, expect, test } from '@playwright/test'; +import { FieldType } from '@prisma/client'; + +import { apiSeedPendingDocument } from '../fixtures/api-seeds'; + +const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL(); + +type SeededGroupEnvelope = { + assistantToken: string; + assistantOwnTextFieldId: number; + peerTextFieldId: number; + peerSignatureFieldId: number; + laterTextFieldId: number; + laterSignatureFieldId: number; +}; + +/** + * Seeds a pending SEQUENTIAL envelope where the ASSISTANT shares a signing + * step (duplicate signingOrder) with a SIGNER: + * + * - Step 1: ASSISTANT (own TEXT field) + "Peer Signer" (SIGNATURE + TEXT). + * - Step 2: "Later Signer" (SIGNATURE + TEXT). + * + * Product rule under signing groups: assistants only assist STRICTLY LATER + * steps — never their own group peers — and never insert SIGNATURE fields + * belonging to anyone else. + */ +const seedGroupedAssistantEnvelope = async (request: APIRequestContext): Promise => { + const timestamp = Date.now(); + + const peerEmail = `peer-signer-${timestamp}@documenso.com`; + const laterEmail = `later-signer-${timestamp}@documenso.com`; + + const { envelope, distributeResult } = await apiSeedPendingDocument(request, { + title: '[TEST] Grouped assistant envelope', + meta: { + signingOrder: 'SEQUENTIAL', + }, + recipients: [ + { + email: `assistant-${timestamp}@documenso.com`, + name: 'Assistant', + role: 'ASSISTANT', + signingOrder: 1, + }, + { + email: peerEmail, + name: 'Peer Signer', + role: 'SIGNER', + signingOrder: 1, + }, + { + email: laterEmail, + name: 'Later Signer', + role: 'SIGNER', + signingOrder: 2, + }, + ], + fieldsPerRecipient: [ + [{ type: FieldType.TEXT, page: 1, positionX: 5, positionY: 5, width: 5, height: 5 }], + [ + { type: FieldType.SIGNATURE, page: 1, positionX: 5, positionY: 15, width: 5, height: 5 }, + { type: FieldType.TEXT, page: 1, positionX: 5, positionY: 25, width: 5, height: 5 }, + ], + [ + { type: FieldType.SIGNATURE, page: 1, positionX: 5, positionY: 35, width: 5, height: 5 }, + { type: FieldType.TEXT, page: 1, positionX: 5, positionY: 45, width: 5, height: 5 }, + ], + ], + }); + + const assistant = distributeResult.recipients.find((r) => r.role === 'ASSISTANT'); + const peer = distributeResult.recipients.find((r) => r.email === peerEmail); + const later = distributeResult.recipients.find((r) => r.email === laterEmail); + + if (!assistant || !peer || !later) { + throw new Error('Seeded recipients not found'); + } + + const fields = await prisma.field.findMany({ + where: { envelopeId: envelope.id }, + }); + + const findField = (recipientId: number, type: FieldType) => { + const field = fields.find((f) => f.recipientId === recipientId && f.type === type); + + if (!field) { + throw new Error(`Field ${type} not found for recipient ${recipientId}`); + } + + return field; + }; + + return { + assistantToken: assistant.token, + assistantOwnTextFieldId: findField(assistant.id, FieldType.TEXT).id, + peerTextFieldId: findField(peer.id, FieldType.TEXT).id, + peerSignatureFieldId: findField(peer.id, FieldType.SIGNATURE).id, + laterTextFieldId: findField(later.id, FieldType.TEXT).id, + laterSignatureFieldId: findField(later.id, FieldType.SIGNATURE).id, + }; +}; + +const trpcMutation = async (request: APIRequestContext, procedure: string, input: Record) => { + return await request.post(`${WEBAPP_BASE_URL}/api/trpc/${procedure}`, { + headers: { 'content-type': 'application/json' }, + data: JSON.stringify({ json: input }), + }); +}; + +test.describe('[ASSISTANT_SIGNING_GROUPS]: same-step (group peer) field access', () => { + test('field.signFieldWithToken (V1) rejects a group peer field', async ({ request }) => { + const { assistantToken, peerTextFieldId } = await seedGroupedAssistantEnvelope(request); + + const res = await trpcMutation(request, 'field.signFieldWithToken', { + token: assistantToken, + fieldId: peerTextFieldId, + value: 'TEXT', + isBase64: false, + }); + + expect(res.ok()).toBeFalsy(); + + const fieldAfter = await prisma.field.findUniqueOrThrow({ + where: { id: peerTextFieldId }, + }); + + expect(fieldAfter.inserted).toBe(false); + expect(fieldAfter.customText).toBe(''); + }); + + test('field.removeSignedFieldWithToken (V1) rejects a group peer field', async ({ request }) => { + const { assistantToken, peerTextFieldId } = await seedGroupedAssistantEnvelope(request); + + // Pre-insert the peer's field so a successful (incorrect) uninsert is detectable. + await prisma.field.update({ + where: { id: peerTextFieldId }, + data: { inserted: true, customText: 'pre-existing-value' }, + }); + + const res = await trpcMutation(request, 'field.removeSignedFieldWithToken', { + token: assistantToken, + fieldId: peerTextFieldId, + }); + + expect(res.ok()).toBeFalsy(); + + const fieldAfter = await prisma.field.findUniqueOrThrow({ + where: { id: peerTextFieldId }, + }); + + expect(fieldAfter.inserted).toBe(true); + expect(fieldAfter.customText).toBe('pre-existing-value'); + }); + + test('envelope.field.sign (V2) rejects a group peer field', async ({ request }) => { + const { assistantToken, peerTextFieldId } = await seedGroupedAssistantEnvelope(request); + + const res = await trpcMutation(request, 'envelope.field.sign', { + token: assistantToken, + fieldId: peerTextFieldId, + fieldValue: { type: FieldType.TEXT, value: 'TEXT' }, + }); + + expect(res.ok()).toBeFalsy(); + + const fieldAfter = await prisma.field.findUniqueOrThrow({ + where: { id: peerTextFieldId }, + }); + + expect(fieldAfter.inserted).toBe(false); + }); + + test('getFieldsForToken excludes group peer fields but keeps own and later-step fields', async ({ request }) => { + const { + assistantToken, + assistantOwnTextFieldId, + peerTextFieldId, + peerSignatureFieldId, + laterTextFieldId, + laterSignatureFieldId, + } = await seedGroupedAssistantEnvelope(request); + + const fields = await getFieldsForToken({ token: assistantToken }); + const fieldIds = fields.map((field) => field.id); + + // Own fields and strictly-later non-signature fields remain visible. + expect(fieldIds).toContain(assistantOwnTextFieldId); + expect(fieldIds).toContain(laterTextFieldId); + + // Group peer fields are never visible to the assistant. + expect(fieldIds).not.toContain(peerTextFieldId); + expect(fieldIds).not.toContain(peerSignatureFieldId); + + // Signature fields of other recipients are never visible to the assistant. + expect(fieldIds).not.toContain(laterSignatureFieldId); + }); +}); + +test.describe('[ASSISTANT_SIGNING_GROUPS]: signature fields of other recipients', () => { + test('field.signFieldWithToken (V1) rejects inserting a later recipient signature field', async ({ request }) => { + const { assistantToken, laterSignatureFieldId } = await seedGroupedAssistantEnvelope(request); + + const res = await trpcMutation(request, 'field.signFieldWithToken', { + token: assistantToken, + fieldId: laterSignatureFieldId, + value: 'John Doe', + isBase64: false, + }); + + expect(res.ok()).toBeFalsy(); + + const fieldAfter = await prisma.field.findUniqueOrThrow({ + where: { id: laterSignatureFieldId }, + include: { signature: true }, + }); + + expect(fieldAfter.inserted).toBe(false); + expect(fieldAfter.signature).toBeNull(); + }); +}); + +test.describe('[ASSISTANT_SIGNING_GROUPS]: preserved assistant abilities', () => { + test('field.signFieldWithToken (V1) still allows filling the assistant own field', async ({ request }) => { + const { assistantToken, assistantOwnTextFieldId } = await seedGroupedAssistantEnvelope(request); + + const res = await trpcMutation(request, 'field.signFieldWithToken', { + token: assistantToken, + fieldId: assistantOwnTextFieldId, + value: 'MY OWN TEXT', + isBase64: false, + }); + + expect(res.ok(), await res.text()).toBeTruthy(); + + const fieldAfter = await prisma.field.findUniqueOrThrow({ + where: { id: assistantOwnTextFieldId }, + }); + + expect(fieldAfter.inserted).toBe(true); + expect(fieldAfter.customText).toBe('MY OWN TEXT'); + }); + + test('field.signFieldWithToken (V1) still allows prefilling a later recipient text field', async ({ request }) => { + const { assistantToken, laterTextFieldId } = await seedGroupedAssistantEnvelope(request); + + const res = await trpcMutation(request, 'field.signFieldWithToken', { + token: assistantToken, + fieldId: laterTextFieldId, + value: 'PREFILLED FOR LATER SIGNER', + isBase64: false, + }); + + expect(res.ok(), await res.text()).toBeTruthy(); + + const fieldAfter = await prisma.field.findUniqueOrThrow({ + where: { id: laterTextFieldId }, + }); + + expect(fieldAfter.inserted).toBe(true); + expect(fieldAfter.customText).toBe('PREFILLED FOR LATER SIGNER'); + }); +}); diff --git a/packages/app-tests/e2e/document-auth/grouped-next-recipient-dictation.spec.ts b/packages/app-tests/e2e/document-auth/grouped-next-recipient-dictation.spec.ts new file mode 100644 index 000000000..c1de93698 --- /dev/null +++ b/packages/app-tests/e2e/document-auth/grouped-next-recipient-dictation.spec.ts @@ -0,0 +1,154 @@ +import { completeDocumentWithToken } from '@documenso/lib/server-only/document/complete-document-with-token'; +import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; +import { prisma } from '@documenso/prisma'; +import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents'; +import { seedUser } from '@documenso/prisma/seed/users'; +import { expect, test } from '@playwright/test'; +import { DocumentSigningOrder, SendStatus } from '@prisma/client'; + +/** + * Dictation lets a signer rewrite who signs next. It cannot be allowed to + * operate on a signing group, for two reasons the server enforces separately: + * + * 1. The next step must hold exactly one recipient (`nextGroup.length === 1`), + * otherwise there is no single "next signer" to rewrite. + * 2. A signer whose own step is still pending (a peer has not signed) does not + * advance the flow at all, so they never reach the dictation branch. + * + * Both are silent — passing `nextSigner` into a state that disallows dictation + * is ignored rather than rejected — which is exactly why they need asserting. + * The existing dictation specs all drive the UI and none use a grouped step. + */ + +const DICTATED_SIGNER = { + name: 'Dictated Signer', + email: 'dictated-signer@example.com', +}; + +const expectRecipientUpdatedAuditLogCount = async (envelopeId: string, expected: number) => { + const auditLogs = await prisma.documentAuditLog.findMany({ + where: { + envelopeId, + type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED, + }, + }); + + expect(auditLogs.length).toBe(expected); +}; + +const seedDictationDocument = async (signingOrders: number[]) => { + const { user, team } = await seedUser(); + + const signers = await Promise.all(signingOrders.map(async () => (await seedUser()).user)); + + const { recipients } = await seedPendingDocumentWithFullFields({ + owner: user, + teamId: team.id, + recipients: signers, + recipientsCreateOptions: signingOrders.map((signingOrder) => ({ + signingOrder, + // The seed defaults every recipient to SENT; later steps of a real + // SEQUENTIAL document are NOT_SENT until their step unlocks. + sendStatus: signingOrder === 1 ? SendStatus.SENT : SendStatus.NOT_SENT, + })), + // No fields, so completion is not blocked by unsigned required fields. + fields: [], + updateDocumentOptions: { + documentMeta: { + upsert: { + create: { + signingOrder: DocumentSigningOrder.SEQUENTIAL, + allowDictateNextSigner: true, + }, + update: { + signingOrder: DocumentSigningOrder.SEQUENTIAL, + allowDictateNextSigner: true, + }, + }, + }, + }, + }); + + return signers.map((signer) => { + const recipient = recipients.find((item) => item.email === signer.email); + + if (!recipient) { + throw new Error(`Seeded recipient ${signer.email} not found`); + } + + return recipient; + }); +}; + +test('[NEXT_RECIPIENT_DICTATION]: dictation is ignored when the next step is a group', async () => { + // Steps: 1 = first, 2 = two grouped recipients. + const [first, groupA, groupB] = await seedDictationDocument([1, 2, 2]); + + await completeDocumentWithToken({ + token: first.token, + id: { type: 'envelopeId', id: first.envelopeId }, + nextSigner: DICTATED_SIGNER, + }); + + const groupAAfter = await prisma.recipient.findUniqueOrThrow({ where: { id: groupA.id } }); + const groupBAfter = await prisma.recipient.findUniqueOrThrow({ where: { id: groupB.id } }); + + // Neither member of the group may be rewritten. + expect(groupAAfter.email).toBe(groupA.email); + expect(groupAAfter.name).toBe(groupA.name); + expect(groupBAfter.email).toBe(groupB.email); + expect(groupBAfter.name).toBe(groupB.name); + + // The dictated identity must not have leaked onto anyone. + const dictated = await prisma.recipient.findFirst({ + where: { envelopeId: first.envelopeId, email: DICTATED_SIGNER.email }, + }); + + expect(dictated).toBeNull(); + + // A rewrite that did not happen must not be recorded as having happened. + await expectRecipientUpdatedAuditLogCount(first.envelopeId, 0); + + // The group is still activated as normal — only the rewrite is suppressed. + expect(groupAAfter.sendStatus).toBe(SendStatus.SENT); + expect(groupBAfter.sendStatus).toBe(SendStatus.SENT); +}); + +test('[NEXT_RECIPIENT_DICTATION]: a group member cannot dictate while a peer is still unsigned', async () => { + // Steps: 1 = two grouped recipients, 2 = last. + const [groupA, groupB, last] = await seedDictationDocument([1, 1, 2]); + + // The first member of the group signs while their peer is still outstanding. + await completeDocumentWithToken({ + token: groupA.token, + id: { type: 'envelopeId', id: groupA.envelopeId }, + nextSigner: DICTATED_SIGNER, + }); + + const lastWhilePeerPending = await prisma.recipient.findUniqueOrThrow({ + where: { id: last.id }, + }); + + // The step never unlocked, so there was nothing to dictate. + expect(lastWhilePeerPending.email).toBe(last.email); + expect(lastWhilePeerPending.name).toBe(last.name); + expect(lastWhilePeerPending.sendStatus).toBe(SendStatus.NOT_SENT); + await expectRecipientUpdatedAuditLogCount(groupA.envelopeId, 0); + + // The peer completing the group *does* advance to a single-recipient step, + // so dictation applies — the positive control for the assertions above. + await completeDocumentWithToken({ + token: groupB.token, + id: { type: 'envelopeId', id: groupB.envelopeId }, + nextSigner: DICTATED_SIGNER, + }); + + const lastAfterGroupComplete = await prisma.recipient.findUniqueOrThrow({ + where: { id: last.id }, + }); + + expect(lastAfterGroupComplete.email).toBe(DICTATED_SIGNER.email); + expect(lastAfterGroupComplete.name).toBe(DICTATED_SIGNER.name); + expect(lastAfterGroupComplete.sendStatus).toBe(SendStatus.SENT); + await expectRecipientUpdatedAuditLogCount(groupA.envelopeId, 1); +}); diff --git a/packages/app-tests/e2e/document-auth/viewer-next-recipient-dictation.spec.ts b/packages/app-tests/e2e/document-auth/viewer-next-recipient-dictation.spec.ts new file mode 100644 index 000000000..899347291 --- /dev/null +++ b/packages/app-tests/e2e/document-auth/viewer-next-recipient-dictation.spec.ts @@ -0,0 +1,150 @@ +import { completeDocumentWithToken } from '@documenso/lib/server-only/document/complete-document-with-token'; +import { prisma } from '@documenso/prisma'; +import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents'; +import { seedUser } from '@documenso/prisma/seed/users'; +import { expect, test } from '@playwright/test'; +import { DocumentSigningOrder, RecipientRole, SendStatus, SigningStatus } from '@prisma/client'; + +/** + * A viewer's completion dialog activates the next-signer validator from + * `allowDictateNextSigner` alone, while the name/email inputs only render + * when a dictatable next recipient exists. When dictation is enabled but the + * next step is not dictatable (a group of two or more, or the viewer is + * last), submission must still work — historically it failed Zod validation + * on the hidden inputs and "Mark as Viewed" silently did nothing. + */ + +const seedViewerDictationDocument = async ( + recipientsCreateOptions: { + signingOrder: number; + role?: RecipientRole; + sendStatus?: SendStatus; + }[], +) => { + const { user, team } = await seedUser(); + + const signers = await Promise.all(recipientsCreateOptions.map(async () => (await seedUser()).user)); + + const { recipients, document } = await seedPendingDocumentWithFullFields({ + owner: user, + teamId: team.id, + recipients: signers, + recipientsCreateOptions, + // No fields, so completion is not blocked by unsigned required fields. + fields: [], + updateDocumentOptions: { + documentMeta: { + upsert: { + create: { + signingOrder: DocumentSigningOrder.SEQUENTIAL, + allowDictateNextSigner: true, + }, + update: { + signingOrder: DocumentSigningOrder.SEQUENTIAL, + allowDictateNextSigner: true, + }, + }, + }, + }, + }); + + return { document, recipients }; +}; + +test('[NEXT_RECIPIENT_DICTATION]: viewer can mark as viewed when the next step is a group', async ({ page }) => { + const { document, recipients } = await seedViewerDictationDocument([ + { signingOrder: 1, role: RecipientRole.VIEWER }, + // The next step is a group of two, so there is no single dictatable next + // recipient — the dialog must not demand one. + { signingOrder: 2, sendStatus: SendStatus.NOT_SENT }, + { signingOrder: 2, sendStatus: SendStatus.NOT_SENT }, + ]); + + const [viewer, groupA, groupB] = recipients; + + const signUrl = `/sign/${viewer.token}`; + + await page.goto(signUrl); + await expect(page.getByRole('heading', { name: 'View Document' })).toBeVisible(); + + const dialog = page.getByRole('dialog'); + + // Retry the click: it can land before hydration attaches the handler. + await expect(async () => { + await page.getByRole('button', { name: 'Mark as viewed', exact: true }).click(); + await expect(dialog).toBeVisible({ timeout: 2_000 }); + }).toPass(); + + // No dictation inputs: a group cannot be dictated over. + await expect(dialog.getByText('Next Recipient Name')).not.toBeVisible(); + + await dialog.getByRole('button', { name: 'Mark as Viewed', exact: true }).click(); + + await page.waitForURL(`${signUrl}/complete`); + + // The viewer completed and the group's step unlocked. + await expect + .poll(async () => { + const updatedRecipients = await prisma.recipient.findMany({ + where: { envelopeId: document.id }, + orderBy: { id: 'asc' }, + }); + + return updatedRecipients.map((recipient) => [recipient.signingStatus, recipient.sendStatus]); + }) + .toEqual([ + [SigningStatus.SIGNED, SendStatus.SENT], + [SigningStatus.NOT_SIGNED, SendStatus.SENT], + [SigningStatus.NOT_SIGNED, SendStatus.SENT], + ]); + + // Nobody was renamed: no next-signer values existed to apply. + const groupAAfter = await prisma.recipient.findUniqueOrThrow({ where: { id: groupA.id } }); + const groupBAfter = await prisma.recipient.findUniqueOrThrow({ where: { id: groupB.id } }); + + expect(groupAAfter.email).toBe(groupA.email); + expect(groupBAfter.email).toBe(groupB.email); +}); + +test('[NEXT_RECIPIENT_DICTATION]: viewer can mark as viewed when they are the last recipient', async ({ page }) => { + const { recipients } = await seedViewerDictationDocument([ + { signingOrder: 1 }, + { signingOrder: 2, role: RecipientRole.VIEWER, sendStatus: SendStatus.NOT_SENT }, + ]); + + const [signer, viewer] = recipients; + + // Advance the flow to the viewer's turn. + await completeDocumentWithToken({ + token: signer.token, + id: { type: 'envelopeId', id: signer.envelopeId }, + }); + + const signUrl = `/sign/${viewer.token}`; + + await page.goto(signUrl); + await expect(page.getByRole('heading', { name: 'View Document' })).toBeVisible(); + + const dialog = page.getByRole('dialog'); + + // Retry the click: it can land before hydration attaches the handler. + await expect(async () => { + await page.getByRole('button', { name: 'Mark as viewed', exact: true }).click(); + await expect(dialog).toBeVisible({ timeout: 2_000 }); + }).toPass(); + + // No dictation inputs: there is nobody after the viewer. + await expect(dialog.getByText('Next Recipient Name')).not.toBeVisible(); + + await dialog.getByRole('button', { name: 'Mark as Viewed', exact: true }).click(); + + await page.waitForURL(`${signUrl}/complete`); + + await expect + .poll(async () => { + const viewerAfter = await prisma.recipient.findUniqueOrThrow({ where: { id: viewer.id } }); + + return viewerAfter.signingStatus; + }) + .toBe(SigningStatus.SIGNED); +}); diff --git a/packages/app-tests/e2e/document-flow/legacy-editor-version-guard.spec.ts b/packages/app-tests/e2e/document-flow/legacy-editor-version-guard.spec.ts new file mode 100644 index 000000000..5f6e3fc7d --- /dev/null +++ b/packages/app-tests/e2e/document-flow/legacy-editor-version-guard.spec.ts @@ -0,0 +1,68 @@ +import { seedBlankDocument } from '@documenso/prisma/seed/documents'; +import { seedBlankTemplate } from '@documenso/prisma/seed/templates'; +import { seedUser } from '@documenso/prisma/seed/users'; +import { expect, test } from '@playwright/test'; + +import { apiSignin } from '../fixtures/authentication'; + +test('[LEGACY_EDITOR]: document legacy editor redirects to the V2 envelope', async ({ page }) => { + const { user, team } = await seedUser(); + + const envelope = await seedBlankDocument(user, team.id, { internalVersion: 2 }); + + await apiSignin({ page, email: user.email }); + + // `page.goto` follows redirects and would report the destination's 200, so + // assert the redirect itself through the (cookie-sharing) request context. + const response = await page.request.get(`/t/${team.url}/documents/${envelope.id}/legacy_editor`, { + maxRedirects: 0, + }); + + expect(response.status()).toBe(302); + expect(response.headers().location).toContain(`/t/${team.url}/documents/${envelope.id}/edit`); +}); + +test('[LEGACY_EDITOR]: template legacy editor redirects to the V2 envelope', async ({ page }) => { + const { user, team } = await seedUser(); + + const envelope = await seedBlankTemplate(user, team.id, { + createTemplateOptions: { internalVersion: 2 }, + }); + + await apiSignin({ page, email: user.email }); + + // `page.goto` follows redirects and would report the destination's 200, so + // assert the redirect itself through the (cookie-sharing) request context. + const response = await page.request.get(`/t/${team.url}/templates/${envelope.id}/legacy_editor`, { + maxRedirects: 0, + }); + + expect(response.status()).toBe(302); + expect(response.headers().location).toContain(`/t/${team.url}/templates/${envelope.id}/edit`); +}); + +test('[LEGACY_EDITOR]: document legacy editor still loads a V1 envelope', async ({ page }) => { + const { user, team } = await seedUser(); + + const envelope = await seedBlankDocument(user, team.id, { internalVersion: 1 }); + + await apiSignin({ page, email: user.email }); + + const response = await page.goto(`/t/${team.url}/documents/${envelope.id}/legacy_editor`); + + expect(response?.status()).toBe(200); +}); + +test('[LEGACY_EDITOR]: template legacy editor still loads a V1 envelope', async ({ page }) => { + const { user, team } = await seedUser(); + + const envelope = await seedBlankTemplate(user, team.id, { + createTemplateOptions: { internalVersion: 1 }, + }); + + await apiSignin({ page, email: user.email }); + + const response = await page.goto(`/t/${team.url}/templates/${envelope.id}/legacy_editor`); + + expect(response?.status()).toBe(200); +}); diff --git a/packages/app-tests/e2e/envelope-editor-v2/envelope-recipient-cc-order.spec.ts b/packages/app-tests/e2e/envelope-editor-v2/envelope-recipient-cc-order.spec.ts index 1286e7230..fc02d8e20 100644 --- a/packages/app-tests/e2e/envelope-editor-v2/envelope-recipient-cc-order.spec.ts +++ b/packages/app-tests/e2e/envelope-editor-v2/envelope-recipient-cc-order.spec.ts @@ -11,7 +11,7 @@ import { assertRecipientRole, getRecipientEmailInputs, getRecipientRows, - getSigningOrderInputs, + getRecipientStepCards, openDocumentEnvelopeEditor, setRecipientEmail, setRecipientName, @@ -34,14 +34,14 @@ const assertCcDisplayedLastWithNoOrderInput = async (root: Page) => { await assertRecipientRole(root, 1, 'Needs to sign'); await assertRecipientRole(root, 2, 'Receives copy'); - // Only the two signers have signing order inputs, showing 1 and 2. - await expect(getSigningOrderInputs(root)).toHaveCount(2); - await expect(getSigningOrderInputs(root).nth(0)).toHaveValue('1'); - await expect(getSigningOrderInputs(root).nth(1)).toHaveValue('2'); + // Only the two signers render as ordered group cards, showing groups 1 and 2. + await expect(getRecipientStepCards(root)).toHaveCount(2); + await expect(root.getByText('Group 1', { exact: true })).toBeVisible(); + await expect(root.getByText('Group 2', { exact: true })).toBeVisible(); - // The CC row itself renders no signing order input (placeholder div instead). + // The CC row itself renders outside the group cards with no drag handle. const ccRow = getRecipientRows(root).nth(2); - await expect(ccRow.locator('[data-testid="signing-order-input"]')).toHaveCount(0); + await expect(ccRow.locator('[data-testid="recipient-row-drag-handle"]')).toHaveCount(0); }; test.describe('document editor', () => { @@ -61,8 +61,8 @@ test.describe('document editor', () => { await setRecipientName(root, 1, CC_RECIPIENT.name); await setRecipientRole(root, 1, 'Receives copy'); - // Once the row becomes CC, its signing order input disappears. - await expect(getSigningOrderInputs(root)).toHaveCount(1); + // Once the row becomes CC, it drops out of the ordered group cards. + await expect(getRecipientStepCards(root)).toHaveCount(1); // Add signer B third. The new row is inserted before the CC recipient, // which is kept last by the client-side sorting. diff --git a/packages/app-tests/e2e/envelope-editor-v2/envelope-recipient-csc-grouping.spec.ts b/packages/app-tests/e2e/envelope-editor-v2/envelope-recipient-csc-grouping.spec.ts new file mode 100644 index 000000000..b7193161c --- /dev/null +++ b/packages/app-tests/e2e/envelope-editor-v2/envelope-recipient-csc-grouping.spec.ts @@ -0,0 +1,259 @@ +import { nanoid } from '@documenso/lib/universal/id'; +import { prisma } from '@documenso/prisma'; +import { expect, type Page, test } from '@playwright/test'; +import { DocumentSigningOrder } from '@prisma/client'; + +import { + clickAddSignerButton, + dragGroupCardOntoCard, + getRecipientEmailInputs, + getRecipientStepCards, + moveGroupCardUp, + openDocumentEnvelopeEditor, + setRecipientEmail, + sweepRecipientRowOverCard, + type TEnvelopeEditorSurface, + toggleSigningOrder, +} from '../fixtures/envelope-editor'; + +/** + * Recipient signing groups are an SES feature: on AES/QES (CSC-mode) + * instances every signing recipient must hold a distinct signing order, so + * the editor must not offer the group affordances (card combine, row-to-card + * join) while still allowing step reordering and ungrouping of invalid + * API-created state. + */ + +const GROUP_BADGE_TEXT = '2 recipients · any order'; + +/** + * Forces the client bundle into CSC mode for this page. + * + * `IS_INSTANCE_CSC_MODE()` reads `window.__ENV__.NEXT_PUBLIC_SIGNING_TRANSPORT_IS_CSC` + * on the client, and `window.__ENV__` is assigned by an inline script during + * hydration — the property trap rewrites the flag whenever that assignment + * happens, regardless of script ordering. + * + * Passed as a raw string: the test runner's esbuild transform decorates + * serialized functions with `__name` helper calls that don't exist in the + * browser, which would make the script throw before installing the trap. + */ +const forceCscClientMode = async (page: Page) => { + await page.addInitScript( + `(() => { + let currentEnv; + + Object.defineProperty(window, '__ENV__', { + configurable: true, + get: () => currentEnv, + set: (value) => { + currentEnv = { ...value, NEXT_PUBLIC_SIGNING_TRANSPORT_IS_CSC: 'true' }; + }, + }); + })();`, + ); +}; + +/** + * CSC envelopes are always SEQUENTIAL, but the seeded blank document defaults + * to PARALLEL and the signing-order toggle is hidden in CSC mode — flip the + * meta directly and reload so the editor renders the sequential step UI. + */ +const makeEnvelopeSequential = async (surface: TEnvelopeEditorSurface) => { + if (!surface.envelopeId) { + throw new Error('Expected surface to have an envelope ID'); + } + + await prisma.envelope.update({ + where: { id: surface.envelopeId }, + data: { + documentMeta: { + update: { signingOrder: DocumentSigningOrder.SEQUENTIAL }, + }, + }, + }); + + await surface.root.reload(); +}; + +const setupTwoSequentialSigners = async (surface: TEnvelopeEditorSurface) => { + const { root } = surface; + + await setRecipientEmail(root, 0, 'alice@example.com'); + + await clickAddSignerButton(root); + await setRecipientEmail(root, 1, 'bob@example.com'); + + await expect(getRecipientStepCards(root)).toHaveCount(2); +}; + +const expectRecipientOrders = async (surface: TEnvelopeEditorSurface, expected: Array<[string, number]>) => { + const { envelopeId } = surface; + + if (!envelopeId) { + throw new Error('Expected surface to have an envelope ID'); + } + + await expect + .poll( + async () => { + const recipients = await prisma.recipient.findMany({ + where: { envelopeId }, + }); + + return recipients.map((r) => [r.email, r.signingOrder] as const).sort((a, b) => a[0].localeCompare(b[0])); + }, + { timeout: 15_000 }, + ) + .toEqual([...expected].sort((a, b) => a[0].localeCompare(b[0]))); +}; + +test.describe('document editor (csc mode)', () => { + test('csc: merging step cards into a group is unavailable', async ({ page }) => { + await forceCscClientMode(page); + + const surface = await openDocumentEnvelopeEditor(page); + + await makeEnvelopeSequential(surface); + await setupTwoSequentialSigners(surface); + + // The keyboard combine helper throws when the target card never enters + // the combine state — exactly what "combining is disabled" looks like. + await expect(dragGroupCardOntoCard(surface.root, 1, 0)).rejects.toThrow( + 'Combine drag did not reach the target card', + ); + + await expect(surface.root.getByText(GROUP_BADGE_TEXT)).not.toBeVisible(); + await expect(getRecipientStepCards(surface.root)).toHaveCount(2); + + await expectRecipientOrders(surface, [ + ['alice@example.com', 1], + ['bob@example.com', 2], + ]); + }); + + test('csc: dropping a recipient row onto a card does not join the group', async ({ page }) => { + await forceCscClientMode(page); + + const surface = await openDocumentEnvelopeEditor(page); + + await makeEnvelopeSequential(surface); + await setupTwoSequentialSigners(surface); + + const sweep = await sweepRecipientRowOverCard(surface.root, 1, 0); + + // The gap zones activating proves the drag itself was live, so the card + // staying inactive is a real refusal rather than a failed gesture. + expect(sweep.sawGapActive).toBe(true); + expect(sweep.sawCardActive).toBe(false); + expect(sweep.dropped).toBe(false); + + await expect(surface.root.getByText(GROUP_BADGE_TEXT)).not.toBeVisible(); + await expect(getRecipientStepCards(surface.root)).toHaveCount(2); + + await expectRecipientOrders(surface, [ + ['alice@example.com', 1], + ['bob@example.com', 2], + ]); + }); + + test('csc: step cards can still be reordered', async ({ page }) => { + await forceCscClientMode(page); + + const surface = await openDocumentEnvelopeEditor(page); + + await makeEnvelopeSequential(surface); + await setupTwoSequentialSigners(surface); + + await moveGroupCardUp(surface.root, 1); + + await expect(getRecipientEmailInputs(surface.root).nth(0)).toHaveValue('bob@example.com'); + await expect(getRecipientEmailInputs(surface.root).nth(1)).toHaveValue('alice@example.com'); + + await expectRecipientOrders(surface, [ + ['alice@example.com', 2], + ['bob@example.com', 1], + ]); + }); + + test('csc: an existing group can still be ungrouped', async ({ page }) => { + await forceCscClientMode(page); + + const surface = await openDocumentEnvelopeEditor(page); + + if (!surface.envelopeId) { + throw new Error('Expected surface to have an envelope ID'); + } + + // A signing group can only exist on a CSC envelope through out-of-band + // writes (API-created state); ungrouping must stay available to repair it. + await prisma.recipient.createMany({ + data: [ + { + envelopeId: surface.envelopeId, + email: 'alice@example.com', + name: 'Alice', + token: nanoid(), + signingOrder: 1, + }, + { + envelopeId: surface.envelopeId, + email: 'bob@example.com', + name: 'Bob', + token: nanoid(), + signingOrder: 1, + }, + ], + }); + + await makeEnvelopeSequential(surface); + + await expect(surface.root.getByText(GROUP_BADGE_TEXT)).toBeVisible(); + + const ungroupButton = surface.root.getByTestId('ungroup-step-button'); + + await expect(ungroupButton).toBeEnabled(); + await ungroupButton.click(); + + await expect(surface.root.getByText(GROUP_BADGE_TEXT)).not.toBeVisible(); + + await expectRecipientOrders(surface, [ + ['alice@example.com', 1], + ['bob@example.com', 2], + ]); + }); +}); + +test.describe('document editor (non-csc control)', () => { + // Control test proving `dragRecipientRowOntoCard` performs a real join when + // grouping is available — without it the disabled-join test above could + // pass vacuously because the drag itself silently failed. + test('control: dropping a recipient row onto a card joins the group', async ({ page }) => { + const surface = await openDocumentEnvelopeEditor(page); + const { root } = surface; + + await setRecipientEmail(root, 0, 'alice@example.com'); + + await clickAddSignerButton(root); + await setRecipientEmail(root, 1, 'bob@example.com'); + + await toggleSigningOrder(root, true); + await expect(getRecipientStepCards(root)).toHaveCount(2); + + // The mouse-driven join drag is timing-sensitive under load, so retry the + // whole gesture until the group forms; a cancelled sweep leaves the order + // untouched, and a completed drop joins the two rows into one step. + await expect(async () => { + const sweep = await sweepRecipientRowOverCard(root, 1, 0); + + expect(sweep.dropped).toBe(true); + + await expect(root.getByText(GROUP_BADGE_TEXT)).toBeVisible({ timeout: 2_000 }); + }).toPass({ timeout: 90_000 }); + + await expectRecipientOrders(surface, [ + ['alice@example.com', 1], + ['bob@example.com', 1], + ]); + }); +}); diff --git a/packages/app-tests/e2e/envelope-editor-v2/envelope-recipient-groups.spec.ts b/packages/app-tests/e2e/envelope-editor-v2/envelope-recipient-groups.spec.ts new file mode 100644 index 000000000..f358e3c6e --- /dev/null +++ b/packages/app-tests/e2e/envelope-editor-v2/envelope-recipient-groups.spec.ts @@ -0,0 +1,137 @@ +import { prisma } from '@documenso/prisma'; +import { expect, test } from '@playwright/test'; + +import { + clickAddSignerButton, + dragGroupCardOntoCard, + dragRecipientRowToGap, + getRecipientEmailInputs, + getRecipientStepCards, + moveGroupCardUp, + openDocumentEnvelopeEditor, + openTemplateEnvelopeEditor, + setRecipientEmail, + setRecipientName, + type TEnvelopeEditorSurface, + toggleSigningOrder, +} from '../fixtures/envelope-editor'; + +const expectRecipientOrders = async (surface: TEnvelopeEditorSurface, expected: Array<[string, number]>) => { + const { envelopeId } = surface; + + if (!envelopeId) { + throw new Error('Expected surface to have an envelope ID'); + } + + await expect + .poll( + async () => { + const recipients = await prisma.recipient.findMany({ + where: { envelopeId }, + }); + + return recipients.map((r) => [r.email, r.signingOrder] as const).sort((a, b) => a[0].localeCompare(b[0])); + }, + { timeout: 15_000 }, + ) + .toEqual([...expected].sort((a, b) => a[0].localeCompare(b[0]))); +}; + +const runGroupingFlow = async (surface: TEnvelopeEditorSurface) => { + const { root } = surface; + + await setRecipientEmail(root, 0, 'alice@example.com'); + await setRecipientName(root, 0, 'Alice'); + + await clickAddSignerButton(root); + await setRecipientEmail(root, 1, 'bob@example.com'); + + await clickAddSignerButton(root); + await setRecipientEmail(root, 2, 'carol@example.com'); + + await toggleSigningOrder(root, true); + + // Three standalone groups. + await expect(root.getByText('Group 1', { exact: true })).toBeVisible(); + await expect(root.getByText('Group 3', { exact: true })).toBeVisible(); + + // Drag carol's card onto bob's card to merge them into one group. + await dragGroupCardOntoCard(root, 2, 1); + + await expect(root.getByText('2 recipients · any order')).toBeVisible(); + await expect(root.getByTestId('ungroup-step-button')).toBeVisible(); + await expect(root.getByText('Group 3', { exact: true })).not.toBeVisible(); + + await expectRecipientOrders(surface, [ + ['alice@example.com', 1], + ['bob@example.com', 2], + ['carol@example.com', 2], + ]); + + // Groups survive a reload (grouped normalization on load). + await root.reload(); + await expect(root.getByText('2 recipients · any order')).toBeVisible(); + + // Ungroup dissolves back into sequential groups. + await root.getByTestId('ungroup-step-button').click(); + + await expect(root.getByText('2 recipients · any order')).not.toBeVisible(); + await expect(root.getByText('Group 3', { exact: true })).toBeVisible(); + + await expectRecipientOrders(surface, [ + ['alice@example.com', 1], + ['bob@example.com', 2], + ['carol@example.com', 3], + ]); + + // Drag bob's row into the gap after the last group, moving him to the end. + await dragRecipientRowToGap(root, 1, 3); + + await expectRecipientOrders(surface, [ + ['alice@example.com', 1], + ['bob@example.com', 3], + ['carol@example.com', 2], + ]); +}; + +test.describe('document editor', () => { + test('documents: group recipients via drag and drop and ungroup', async ({ page }) => { + const surface = await openDocumentEnvelopeEditor(page); + + await runGroupingFlow(surface); + }); + + test('documents: reordered group cards can still be dragged', async ({ page }) => { + const surface = await openDocumentEnvelopeEditor(page); + const { root } = surface; + + await setRecipientEmail(root, 0, 'alice@example.com'); + await clickAddSignerButton(root); + await setRecipientEmail(root, 1, 'bob@example.com'); + + await toggleSigningOrder(root, true); + await expect(getRecipientStepCards(root)).toHaveCount(2); + + // Move bob's card into position 1. + await moveGroupCardUp(root, 1); + + await expect(getRecipientEmailInputs(root).nth(0)).toHaveValue('bob@example.com'); + await expect(getRecipientEmailInputs(root).nth(1)).toHaveValue('alice@example.com'); + + // Regression: after a reorder, the card moved into position 2 must still + // be draggable — positional drag-and-drop ids used to go stale on mounted + // cards, silently killing their drag handles. Prove it by completing a + // merge with the repositioned card. + await dragGroupCardOntoCard(root, 1, 0); + + await expect(root.getByText('2 recipients · any order')).toBeVisible(); + }); +}); + +test.describe('template editor', () => { + test('templates: group recipients via drag and drop and ungroup', async ({ page }) => { + const surface = await openTemplateEnvelopeEditor(page); + + await runGroupingFlow(surface); + }); +}); diff --git a/packages/app-tests/e2e/envelope-editor-v2/envelope-recipient-id-stability.spec.ts b/packages/app-tests/e2e/envelope-editor-v2/envelope-recipient-id-stability.spec.ts new file mode 100644 index 000000000..7871fa385 --- /dev/null +++ b/packages/app-tests/e2e/envelope-editor-v2/envelope-recipient-id-stability.spec.ts @@ -0,0 +1,124 @@ +import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; +import { prisma } from '@documenso/prisma'; +import { expect, test } from '@playwright/test'; + +import { + clickAddSignerButton, + openDocumentEnvelopeEditor, + openTemplateEnvelopeEditor, + setRecipientEmail, + setRecipientName, + type TEnvelopeEditorSurface, +} from '../fixtures/envelope-editor'; + +/** + * A newly added recipient is created by the first autosave, and the editor + * must adopt the server-assigned id for subsequent saves. Historically the id + * was never synced back into the form while staying on the recipients step, + * so every following autosave resent the signer id-less — the server deleted + * the previously created row and recreated it with a fresh id and signing + * token, polluting the audit log with removed/added pairs on every edit. + */ + +const getRecipientByEmail = async (surface: TEnvelopeEditorSurface, email: string) => { + const { envelopeId } = surface; + + if (!envelopeId) { + throw new Error('Expected surface to have an envelope ID'); + } + + await expect.poll(async () => prisma.recipient.count({ where: { envelopeId, email } }), { timeout: 15_000 }).toBe(1); + + return await prisma.recipient.findFirstOrThrow({ where: { envelopeId, email } }); +}; + +const waitForRecipientName = async (surface: TEnvelopeEditorSurface, email: string, name: string) => { + await expect + .poll( + async () => { + const recipient = await prisma.recipient.findFirst({ + where: { envelopeId: surface.envelopeId, email }, + }); + + return recipient?.name; + }, + { timeout: 15_000 }, + ) + .toBe(name); +}; + +test.describe('document editor', () => { + test('documents: recipient id and token remain stable across autosaves', async ({ page }) => { + const surface = await openDocumentEnvelopeEditor(page); + const { root, envelopeId } = surface; + + await setRecipientEmail(root, 0, 'alice@example.com'); + await setRecipientName(root, 0, 'Alice'); + + const aliceInitial = await getRecipientByEmail(surface, 'alice@example.com'); + + // Edit while staying on the recipients step: the same row must be + // updated, not deleted and recreated. + await setRecipientName(root, 0, 'Alice Two'); + await waitForRecipientName(surface, 'alice@example.com', 'Alice Two'); + + const aliceAfterEdit = await getRecipientByEmail(surface, 'alice@example.com'); + + expect(aliceAfterEdit.id).toBe(aliceInitial.id); + expect(aliceAfterEdit.token).toBe(aliceInitial.token); + + // Adding another signer resends the whole set — alice must survive it, + // and bob must then survive an edit to alice. + await clickAddSignerButton(root); + await setRecipientEmail(root, 1, 'bob@example.com'); + + const bobInitial = await getRecipientByEmail(surface, 'bob@example.com'); + + await setRecipientName(root, 0, 'Alice Three'); + await waitForRecipientName(surface, 'alice@example.com', 'Alice Three'); + + const aliceFinal = await getRecipientByEmail(surface, 'alice@example.com'); + const bobFinal = await getRecipientByEmail(surface, 'bob@example.com'); + + expect(aliceFinal.id).toBe(aliceInitial.id); + expect(aliceFinal.token).toBe(aliceInitial.token); + expect(bobFinal.id).toBe(bobInitial.id); + expect(bobFinal.token).toBe(bobInitial.token); + + // One creation per recipient and zero deletions in the audit trail. + const auditLogs = await prisma.documentAuditLog.findMany({ + where: { + envelopeId, + type: { + in: [DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED, DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_DELETED], + }, + }, + }); + + const createdCount = auditLogs.filter((log) => log.type === DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED).length; + const deletedCount = auditLogs.filter((log) => log.type === DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_DELETED).length; + + expect(deletedCount).toBe(0); + expect(createdCount).toBe(2); + }); +}); + +test.describe('template editor', () => { + test('templates: recipient id and token remain stable across autosaves', async ({ page }) => { + const surface = await openTemplateEnvelopeEditor(page); + const { root } = surface; + + await setRecipientEmail(root, 0, 'alice@example.com'); + await setRecipientName(root, 0, 'Alice'); + + const aliceInitial = await getRecipientByEmail(surface, 'alice@example.com'); + + await setRecipientName(root, 0, 'Alice Two'); + await waitForRecipientName(surface, 'alice@example.com', 'Alice Two'); + + const aliceAfterEdit = await getRecipientByEmail(surface, 'alice@example.com'); + + expect(aliceAfterEdit.id).toBe(aliceInitial.id); + expect(aliceAfterEdit.token).toBe(aliceInitial.token); + }); +}); diff --git a/packages/app-tests/e2e/envelope-editor-v2/envelope-recipient-locked-steps.spec.ts b/packages/app-tests/e2e/envelope-editor-v2/envelope-recipient-locked-steps.spec.ts new file mode 100644 index 000000000..59884a9b9 --- /dev/null +++ b/packages/app-tests/e2e/envelope-editor-v2/envelope-recipient-locked-steps.spec.ts @@ -0,0 +1,168 @@ +import { prisma } from '@documenso/prisma'; +import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents'; +import { seedUser } from '@documenso/prisma/seed/users'; +import { expect, test } from '@playwright/test'; +import { DocumentSigningOrder, SigningStatus } from '@prisma/client'; + +import { apiSignin } from '../fixtures/authentication'; +import { getRecipientStepCards } from '../fixtures/envelope-editor'; + +/** + * Signing is sequential, so a recipient who has already acted is at or before + * the current step. Those steps hold persisted signing orders that the server + * will not let us rewrite, so ordering is locked up to and including the last + * of them. Later steps can only contain recipients who have not acted, so they + * stay fully rearrangeable. + */ + +test('[LOCKED_STEPS]: ordering is locked up to the signed step and free afterwards', async ({ page }) => { + const { user, team } = await seedUser(); + const { user: signed } = await seedUser(); + const { user: signedPeer } = await seedUser(); + const { user: pendingB } = await seedUser(); + const { user: pendingC } = await seedUser(); + + const { document } = await seedPendingDocumentWithFullFields({ + owner: user, + teamId: team.id, + recipients: [signed, signedPeer, pendingB, pendingC], + recipientsCreateOptions: [ + // Step 1 is a group, and one of its members has signed. + { signingOrder: 1, signingStatus: SigningStatus.SIGNED }, + { signingOrder: 1, signingStatus: SigningStatus.NOT_SIGNED }, + { signingOrder: 2, signingStatus: SigningStatus.NOT_SIGNED }, + { signingOrder: 3, signingStatus: SigningStatus.NOT_SIGNED }, + ], + fields: [], + updateDocumentOptions: { + internalVersion: 2, + documentMeta: { + upsert: { + create: { signingOrder: DocumentSigningOrder.SEQUENTIAL }, + update: { signingOrder: DocumentSigningOrder.SEQUENTIAL }, + }, + }, + }, + }); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/t/${team.url}/documents/${document.id}/edit?step=uploadAndRecipients`, + }); + + await expect(getRecipientStepCards(page)).toHaveCount(3); + + const stepHandles = page.getByTestId('step-drag-handle'); + + // Step 1 contains a signed recipient, so it is locked. + await expect(stepHandles.nth(0)).toHaveClass(/pointer-events-none/); + + // Its ungroup control is unavailable too — splitting it would rewrite the + // signed recipient's persisted order. + await expect(page.getByTestId('ungroup-step-button')).toBeDisabled(); + + // Steps after it hold only recipients who cannot have acted yet. + await expect(stepHandles.nth(1)).not.toHaveClass(/pointer-events-none/); + await expect(stepHandles.nth(2)).not.toHaveClass(/pointer-events-none/); + + // Nothing was rewritten by simply opening the editor. + const recipients = await prisma.recipient.findMany({ where: { envelopeId: document.id } }); + + expect(recipients.find((r) => r.email === signed.email)?.signingOrder).toBe(1); + expect(recipients.find((r) => r.email === signedPeer.email)?.signingOrder).toBe(1); + expect(recipients.find((r) => r.email === pendingB.email)?.signingOrder).toBe(2); + expect(recipients.find((r) => r.email === pendingC.email)?.signingOrder).toBe(3); +}); + +/** + * A signed recipient can sit out of sequence — a direct template signs at its + * own template order, field insertion has no turn check, and a document can be + * switched from parallel to sequential mid-flight. The rule is "up to and + * including the last signed step" rather than "the signed prefix" precisely so + * these stay safe: the earlier unsigned step is locked too. + */ +test('[LOCKED_STEPS]: a signed recipient mid-sequence locks the steps before it', async ({ page }) => { + const { user, team } = await seedUser(); + const { user: firstPending } = await seedUser(); + const { user: signedSecond } = await seedUser(); + const { user: lastPending } = await seedUser(); + + const { document } = await seedPendingDocumentWithFullFields({ + owner: user, + teamId: team.id, + recipients: [firstPending, signedSecond, lastPending], + recipientsCreateOptions: [ + { signingOrder: 1, signingStatus: SigningStatus.NOT_SIGNED }, + { signingOrder: 2, signingStatus: SigningStatus.SIGNED }, + { signingOrder: 3, signingStatus: SigningStatus.NOT_SIGNED }, + ], + fields: [], + updateDocumentOptions: { + internalVersion: 2, + documentMeta: { + upsert: { + create: { signingOrder: DocumentSigningOrder.SEQUENTIAL }, + update: { signingOrder: DocumentSigningOrder.SEQUENTIAL }, + }, + }, + }, + }); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/t/${team.url}/documents/${document.id}/edit?step=uploadAndRecipients`, + }); + + await expect(getRecipientStepCards(page)).toHaveCount(3); + + const stepHandles = page.getByTestId('step-drag-handle'); + + // Step 1 has no signed recipient, but it sits before one — moving it would + // reshuffle the signed recipient's position, so it is locked as well. + await expect(stepHandles.nth(0)).toHaveClass(/pointer-events-none/); + await expect(stepHandles.nth(1)).toHaveClass(/pointer-events-none/); + + // Only the step after the signed one remains movable. + await expect(stepHandles.nth(2)).not.toHaveClass(/pointer-events-none/); +}); + +test('[LOCKED_STEPS]: every step stays draggable when nobody has signed', async ({ page }) => { + const { user, team } = await seedUser(); + const { user: first } = await seedUser(); + const { user: second } = await seedUser(); + + const { document } = await seedPendingDocumentWithFullFields({ + owner: user, + teamId: team.id, + recipients: [first, second], + recipientsCreateOptions: [ + { signingOrder: 1, signingStatus: SigningStatus.NOT_SIGNED }, + { signingOrder: 2, signingStatus: SigningStatus.NOT_SIGNED }, + ], + fields: [], + updateDocumentOptions: { + internalVersion: 2, + documentMeta: { + upsert: { + create: { signingOrder: DocumentSigningOrder.SEQUENTIAL }, + update: { signingOrder: DocumentSigningOrder.SEQUENTIAL }, + }, + }, + }, + }); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/t/${team.url}/documents/${document.id}/edit?step=uploadAndRecipients`, + }); + + await expect(getRecipientStepCards(page)).toHaveCount(2); + + const stepHandles = page.getByTestId('step-drag-handle'); + + await expect(stepHandles.nth(0)).not.toHaveClass(/pointer-events-none/); + await expect(stepHandles.nth(1)).not.toHaveClass(/pointer-events-none/); +}); diff --git a/packages/app-tests/e2e/envelope-editor-v2/envelope-recipient-null-order-hydration.spec.ts b/packages/app-tests/e2e/envelope-editor-v2/envelope-recipient-null-order-hydration.spec.ts new file mode 100644 index 000000000..e82fbb26b --- /dev/null +++ b/packages/app-tests/e2e/envelope-editor-v2/envelope-recipient-null-order-hydration.spec.ts @@ -0,0 +1,100 @@ +import { prisma } from '@documenso/prisma'; +import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents'; +import { seedUser } from '@documenso/prisma/seed/users'; +import { expect, test } from '@playwright/test'; +import { DocumentSigningOrder, SigningStatus } from '@prisma/client'; + +import { apiSignin } from '../fixtures/authentication'; +import { getRecipientEmailInputs, getRecipientStepCards, setRecipientName } from '../fixtures/envelope-editor'; + +/** + * A recipient with no persisted signing order means "last" everywhere on the + * server (queries sort NULLS LAST, and `effectiveOrder` maps null to the end). + * The editor must not invent an order from array position: the guess can land + * on a real order — which now means "same signing step" — or move the + * recipient ahead of one that was meant to sign first. + */ + +const seedMixedOrderEnvelope = async (options: { firstOrder: number }) => { + const { user, team } = await seedUser(); + const { user: ordered } = await seedUser(); + const { user: unordered } = await seedUser(); + + const { document } = await seedPendingDocumentWithFullFields({ + owner: user, + teamId: team.id, + recipients: [ordered, unordered], + recipientsCreateOptions: [ + { signingOrder: options.firstOrder, signingStatus: SigningStatus.NOT_SIGNED }, + // Created second, so it takes the higher id — this is the position the + // editor used to turn into `index + 1`. + { signingOrder: null, signingStatus: SigningStatus.NOT_SIGNED }, + ], + fields: [], + updateDocumentOptions: { + internalVersion: 2, + documentMeta: { + upsert: { + create: { signingOrder: DocumentSigningOrder.SEQUENTIAL }, + update: { signingOrder: DocumentSigningOrder.SEQUENTIAL }, + }, + }, + }, + }); + + return { user, team, document, orderedEmail: ordered.email, unorderedEmail: unordered.email }; +}; + +test('[NULL_ORDER_HYDRATION]: a null-order recipient does not join an existing step', async ({ page }) => { + // The unordered recipient sits at index 1, so `index + 1` would collide with + // the persisted order 2 and render the two as one group. + const { user, team, document, orderedEmail, unorderedEmail } = await seedMixedOrderEnvelope({ firstOrder: 2 }); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/t/${team.url}/documents/${document.id}/edit?step=uploadAndRecipients`, + }); + + await expect(getRecipientEmailInputs(page)).toHaveCount(2); + + // Two independent steps, not a single group. + await expect(getRecipientStepCards(page)).toHaveCount(2); + await expect(page.getByText('2 recipients · any order')).not.toBeVisible(); + + // Persisted orders must stay distinct once the editor saves. + await setRecipientName(page, 1, 'Renamed Unordered'); + + await expect + .poll(async () => { + const recipients = await prisma.recipient.findMany({ where: { envelopeId: document.id } }); + + return recipients.find((recipient) => recipient.email === unorderedEmail)?.name; + }) + .toBe('Renamed Unordered'); + + const recipients = await prisma.recipient.findMany({ where: { envelopeId: document.id } }); + + const orderedRecipient = recipients.find((recipient) => recipient.email === orderedEmail); + const unorderedRecipient = recipients.find((recipient) => recipient.email === unorderedEmail); + + expect(orderedRecipient?.signingOrder).not.toBe(unorderedRecipient?.signingOrder); +}); + +test('[NULL_ORDER_HYDRATION]: a null-order recipient stays last', async ({ page }) => { + // Persisted order 3 with the unordered recipient at index 1: `index + 1` + // would give it 2 and move it ahead of the recipient meant to sign first. + const { user, team, document, orderedEmail, unorderedEmail } = await seedMixedOrderEnvelope({ firstOrder: 3 }); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/t/${team.url}/documents/${document.id}/edit?step=uploadAndRecipients`, + }); + + await expect(getRecipientEmailInputs(page)).toHaveCount(2); + + // The ordered recipient must still be shown first. + await expect(getRecipientEmailInputs(page).nth(0)).toHaveValue(orderedEmail); + await expect(getRecipientEmailInputs(page).nth(1)).toHaveValue(unorderedEmail); +}); diff --git a/packages/app-tests/e2e/envelope-editor-v2/envelope-recipients.spec.ts b/packages/app-tests/e2e/envelope-editor-v2/envelope-recipients.spec.ts index c2e9d6cba..881d2be60 100644 --- a/packages/app-tests/e2e/envelope-editor-v2/envelope-recipients.spec.ts +++ b/packages/app-tests/e2e/envelope-editor-v2/envelope-recipients.spec.ts @@ -9,11 +9,12 @@ import { clickAddMyselfButton, clickAddSignerButton, clickEnvelopeEditorStep, + dragRecipientRowToGap, getEnvelopeEditorSettingsTrigger, getRecipientEmailInputs, getRecipientNameInputs, getRecipientRemoveButtons, - getSigningOrderInputs, + getRecipientStepCards, openDocumentEnvelopeEditor, openEmbeddedEnvelopeEditor, openTemplateEnvelopeEditor, @@ -21,7 +22,6 @@ import { setRecipientEmail, setRecipientName, setRecipientRole, - setSigningOrderValue, type TEnvelopeEditorSurface, toggleAllowDictateSigners, toggleSigningOrder, @@ -112,46 +112,71 @@ const runRecipientFlow = async (surface: TEnvelopeEditorSurface): Promise export const getRecipientRemoveButtons = (root: Page) => root.locator('[data-testid="remove-signer-button"]'); -export const getSigningOrderInputs = (root: Page) => root.locator('[data-testid="signing-order-input"]'); - export const clickEnvelopeEditorStep = async (root: Page, stepId: 'upload' | 'addFields' | 'preview') => { await root.waitForTimeout(200); await root.locator(`[data-testid="envelope-editor-step-${stepId}"]`).first().click(); @@ -335,10 +333,334 @@ export const toggleAllowDictateSigners = async (root: Page, enabled: boolean) => } }; -export const setSigningOrderValue = async (root: Page, index: number, value: number) => { - const input = getSigningOrderInputs(root).nth(index); - await input.fill(value.toString()); - await input.blur(); +/** + * Performs a mouse-based drag from a drag handle onto a target element. + * + * `@hello-pangea/dnd` only starts a drag once the pointer travels a small + * distance while pressed, and it hit-tests drop targets using the CENTRE of + * the dragged element — not the cursor. Since drag handles sit at the edge of + * wide rows/cards, the cursor destination is compensated so the dragged + * element's centre lands on the target's centre. + */ +export const dragHandleToTarget = async ( + root: Page, + handle: Locator, + target: Locator, + options: { activeClass: string }, +) => { + const { activeClass } = options; + + await handle.scrollIntoViewIfNeeded(); + + const handleBox = await handle.boundingBox(); + + if (!handleBox) { + throw new Error('Unable to resolve drag handle position'); + } + + const startX = handleBox.x + handleBox.width / 2; + const startY = handleBox.y + handleBox.height / 2; + + await root.mouse.move(startX, startY); + await root.mouse.down(); + + // Exceed the drag activation threshold, then wait for drag-dependent layout + // (e.g. expanding gap drop-zones) to settle before resolving positions. + const cursorX = startX + 8; + const cursorY = startY; + + await root.mouse.move(cursorX, cursorY, { steps: 2 }); + await root.waitForTimeout(300); + + // The dragged element is the handle's draggable ancestor; while dragging it + // is fixed-positioned and follows the cursor at a constant offset. Drop + // targeting uses the dragged element's CENTRE, not the cursor, so the + // cursor destination is compensated by that offset. + const draggedElement = handle.locator('xpath=ancestor-or-self::*[@data-rfd-draggable-id][1]'); + const draggedBox = await draggedElement.boundingBox(); + const targetBox = await target.boundingBox(); + + if (!draggedBox || !targetBox) { + await root.mouse.up(); + + throw new Error('Unable to resolve drag positions'); + } + + const itemOffsetX = draggedBox.x + draggedBox.width / 2 - cursorX; + const itemOffsetY = draggedBox.y + draggedBox.height / 2 - cursorY; + + const hasBecomeActive = async () => { + const className = await target.getAttribute('class'); + + return Boolean(className?.includes(activeClass)); + }; + + // The highlight class is rendered from the library's own drag state, so it + // cannot disagree with where a drop will land — both phases below only drop + // once the target reports the drag as over it AND that state survives a + // short confirmation dwell (it can flicker while crossing a card's + // reorder/combine boundary). + // + // The cursor is always clamped inside the viewport: moving outside the + // window cancels the drag (pointercancel), and holding near the bottom edge + // lets the library auto-scroll the target up to the cursor instead. + const viewportHeight = root.viewportSize()?.height ?? 720; + const maxCursorY = viewportHeight - 40; + + const confirmAndDrop = async () => { + if (!(await hasBecomeActive())) { + return false; + } + + await root.waitForTimeout(150); + + if (!(await hasBecomeActive())) { + return false; + } + + await root.mouse.up(); + + return true; + }; + + let hasDropped = false; + + // Crawl-and-drop: approach from above and inch downward through the + // corridor. Captured drop-target geometry can drift a few pixels from the + // live layout for small targets, so a slow traversal is the reliable way to + // hit them. + const crawlX = targetBox.x + targetBox.width / 2 - itemOffsetX; + const crawlStartY = Math.min(targetBox.y + targetBox.height / 2 - itemOffsetY - 140, maxCursorY); + + await root.mouse.move(crawlX, crawlStartY, { steps: 15 }); + await root.waitForTimeout(150); + + for (let step = 1; step <= 80; step += 1) { + if (await confirmAndDrop()) { + hasDropped = true; + + break; + } + + await root.mouse.move(crawlX, Math.min(crawlStartY + step * 6, maxCursorY), { steps: 2 }); + await root.waitForTimeout(70); + } + + if (!hasDropped) { + await root.mouse.up(); + } + + await root.waitForTimeout(400); +}; + +export const getRecipientStepCards = (root: Page) => root.locator('[data-testid="recipient-step-card"]'); + +export const getRecipientStepGaps = (root: Page) => root.locator('[data-testid="recipient-step-gap"]'); + +export const getStepDragHandles = (root: Page) => root.locator('[data-testid="step-drag-handle"]'); + +export const getRecipientRowDragHandles = (root: Page) => root.locator('[data-testid="recipient-row-drag-handle"]'); + +/** + * Drags a whole group card onto another card, merging the two groups. + * + * Uses @hello-pangea/dnd's keyboard drag mode: mouse-emulated combines are + * unreliable because approaching a card traverses its reorder edge, which + * displaces the target away from the cursor. Keyboard drags step through + * positions (including combine states) deterministically. + */ +export const dragGroupCardOntoCard = async (root: Page, sourceCardIndex: number, targetCardIndex: number) => { + const handle = getStepDragHandles(root).nth(sourceCardIndex); + const target = getRecipientStepCards(root).nth(targetCardIndex); + + await handle.scrollIntoViewIfNeeded(); + await handle.focus(); + + // Lift. + await root.keyboard.press('Space'); + await root.waitForTimeout(250); + + const direction = targetCardIndex < sourceCardIndex ? 'ArrowUp' : 'ArrowDown'; + + for (let press = 0; press < 4; press += 1) { + await root.keyboard.press(direction); + await root.waitForTimeout(250); + + const targetClassName = await target.getAttribute('class'); + + if (targetClassName?.includes('ring-primary')) { + // Drop while the target reports the combine state. + await root.keyboard.press('Space'); + await root.waitForTimeout(400); + + return; + } + } + + await root.keyboard.press('Escape'); + + throw new Error('Combine drag did not reach the target card'); +}; + +/** + * Moves a group card one position up via keyboard drag. With combining + * enabled, the first ArrowUp enters the combine state with the card above and + * the second moves above it. + */ +export const moveGroupCardUp = async (root: Page, cardIndex: number) => { + const handle = getStepDragHandles(root).nth(cardIndex); + + await handle.scrollIntoViewIfNeeded(); + await handle.focus(); + + await root.keyboard.press('Space'); + await root.waitForTimeout(250); + await root.keyboard.press('ArrowUp'); + await root.waitForTimeout(250); + await root.keyboard.press('ArrowUp'); + await root.waitForTimeout(250); + await root.keyboard.press('Space'); + await root.waitForTimeout(400); +}; + +/** + * Drags a recipient row into a gap between group cards, extracting it into + * its own standalone group at that position. + */ +export const dragRecipientRowToGap = async (root: Page, rowIndex: number, gapIndex: number) => { + await dragHandleToTarget( + root, + getRecipientRowDragHandles(root).nth(rowIndex), + getRecipientStepGaps(root).nth(gapIndex), + // The marker class applied to a gap drop-zone while dragged over. + { activeClass: 'gap-active' }, + ); +}; + +export type SweepRecipientRowOverCardResult = { + /** + * Whether any gap drop-zone activated during the sweep — proof the drag + * gesture itself was live, so "the card never activated" cannot be a + * false negative from a drag that silently failed to start. + */ + sawGapActive: boolean; + /** + * Whether the target card reported the row as a join target (`ring-primary`). + */ + sawCardActive: boolean; + /** + * Whether the row was dropped onto the card (only when it became active). + */ + dropped: boolean; +}; + +/** + * Drags a recipient row across a group card's body, dropping it to join the + * group as soon as the card activates. If the card never activates (e.g. the + * join drop-zone is disabled), the drag is cancelled with Escape so no + * accidental gap-drop mutates the order. + * + * Unlike `dragHandleToTarget`'s fixed-interval crawl, each sweep position + * polls for activation with a generous budget, which keeps the gesture + * reliable when rendering lags under parallel test load. + */ +export const sweepRecipientRowOverCard = async ( + root: Page, + rowIndex: number, + cardIndex: number, +): Promise => { + const handle = getRecipientRowDragHandles(root).nth(rowIndex); + const card = getRecipientStepCards(root).nth(cardIndex); + + const result: SweepRecipientRowOverCardResult = { + sawGapActive: false, + sawCardActive: false, + dropped: false, + }; + + await handle.scrollIntoViewIfNeeded(); + + const handleBox = await handle.boundingBox(); + + if (!handleBox) { + throw new Error('Unable to resolve drag handle position'); + } + + const startX = handleBox.x + handleBox.width / 2; + const startY = handleBox.y + handleBox.height / 2; + + await root.mouse.move(startX, startY); + await root.mouse.down(); + + // Exceed the drag activation threshold, then wait for drag-dependent + // layout (expanding drop-zones) to settle before resolving positions. + const cursorX = startX + 8; + const cursorY = startY; + + await root.mouse.move(cursorX, cursorY, { steps: 2 }); + await root.waitForTimeout(300); + + // Drop targeting uses the dragged element's CENTRE, not the cursor, so + // cursor destinations are compensated by the constant cursor-to-centre + // offset captured at lift time. + const draggedElement = handle.locator('xpath=ancestor-or-self::*[@data-rfd-draggable-id][1]'); + const draggedBox = await draggedElement.boundingBox(); + const cardBox = await card.boundingBox(); + + if (!draggedBox || !cardBox) { + await root.mouse.up(); + + throw new Error('Unable to resolve drag positions'); + } + + const itemOffsetX = draggedBox.x + draggedBox.width / 2 - cursorX; + const itemOffsetY = draggedBox.y + draggedBox.height / 2 - cursorY; + + const sweepX = cardBox.x + cardBox.width / 2 - itemOffsetX; + const sweepFromY = cardBox.y - itemOffsetY - 40; + const sweepToY = cardBox.y + cardBox.height - itemOffsetY + 80; + + await root.mouse.move(sweepX, sweepFromY, { steps: 15 }); + + for (let y = sweepFromY; y <= sweepToY && !result.dropped; y += 8) { + await root.mouse.move(sweepX, y, { steps: 2 }); + + // Poll for activation: drag state is rendered on animation frames, so + // under load the classes can trail the cursor by hundreds of ms. + for (let tick = 0; tick < 6; tick += 1) { + const cardClassName = (await card.getAttribute('class')) ?? ''; + + if (cardClassName.includes('ring-primary')) { + result.sawCardActive = true; + + await root.mouse.up(); + + result.dropped = true; + + break; + } + + if (!result.sawGapActive) { + const activeGapCount = await root.locator('[data-testid="recipient-step-gap"].gap-active').count(); + + result.sawGapActive = activeGapCount > 0; + } + + await root.waitForTimeout(50); + } + } + + if (!result.dropped) { + // Cancel rather than release: releasing over an active gap would extract + // the row into a new step, silently mutating the signing order. + await root.keyboard.press('Escape'); + await root.waitForTimeout(100); + await root.mouse.up(); + } + + await root.waitForTimeout(400); + + return result; }; export const persistEmbeddedEnvelope = async (surface: TEnvelopeEditorSurface) => { diff --git a/packages/app-tests/e2e/recipient/rejected-recipient-advancement.spec.ts b/packages/app-tests/e2e/recipient/rejected-recipient-advancement.spec.ts new file mode 100644 index 000000000..743e4901f --- /dev/null +++ b/packages/app-tests/e2e/recipient/rejected-recipient-advancement.spec.ts @@ -0,0 +1,89 @@ +import { completeDocumentWithToken } from '@documenso/lib/server-only/document/complete-document-with-token'; +import { prisma } from '@documenso/prisma'; +import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents'; +import { seedUser } from '@documenso/prisma/seed/users'; +import { expect, test } from '@playwright/test'; +import { DocumentSigningOrder, SigningStatus } from '@prisma/client'; + +/** + * Rejecting a document only marks the recipient; the envelope is moved to + * REJECTED later, asynchronously, by the seal job. Until that lands the + * envelope is still PENDING, so another recipient can complete and the + * advancement logic runs with a REJECTED recipient in the list. + * + * That recipient must never be treated as the next signing group — doing so + * re-marks them as sent and emails them a signing request for a document they + * declined. (For rejected TSP envelopes the seal job always throws, so this + * state is permanent rather than a narrow race.) + */ + +const expectSigningRequestJobCount = async (recipientId: number, expected: number) => { + const jobs = await prisma.backgroundJob.findMany({ + where: { + jobId: 'send.signing.requested.email', + payload: { + path: ['recipientId'], + equals: recipientId, + }, + }, + }); + + expect(jobs.length).toBe(expected); +}; + +test('[REJECTED_ADVANCEMENT]: a rejected recipient is skipped when the next group is activated', async () => { + const { user, team } = await seedUser(); + const { user: firstSigner } = await seedUser(); + const { user: rejectedSigner } = await seedUser(); + const { user: laterSigner } = await seedUser(); + + const { recipients } = await seedPendingDocumentWithFullFields({ + owner: user, + teamId: team.id, + recipients: [firstSigner, rejectedSigner, laterSigner], + recipientsCreateOptions: [ + { signingOrder: 1, signingStatus: SigningStatus.NOT_SIGNED }, + { signingOrder: 2, signingStatus: SigningStatus.REJECTED }, + { signingOrder: 3, signingStatus: SigningStatus.NOT_SIGNED }, + ], + // No fields, so completion is not blocked by unsigned required fields. + fields: [], + updateDocumentOptions: { + documentMeta: { + upsert: { + create: { signingOrder: DocumentSigningOrder.SEQUENTIAL }, + update: { signingOrder: DocumentSigningOrder.SEQUENTIAL }, + }, + }, + }, + }); + + const first = recipients.find((recipient) => recipient.email === firstSigner.email); + const rejected = recipients.find((recipient) => recipient.email === rejectedSigner.email); + const later = recipients.find((recipient) => recipient.email === laterSigner.email); + + if (!first || !rejected || !later) { + throw new Error('Seeded recipients not found'); + } + + // The seed never sets sentAt, so it is a clean signal for "was activated". + expect(rejected.sentAt).toBeNull(); + expect(later.sentAt).toBeNull(); + + await completeDocumentWithToken({ + token: first.token, + id: { type: 'envelopeId', id: first.envelopeId }, + }); + + const rejectedAfter = await prisma.recipient.findUniqueOrThrow({ where: { id: rejected.id } }); + const laterAfter = await prisma.recipient.findUniqueOrThrow({ where: { id: later.id } }); + + // The rejected recipient is left alone entirely. + expect(rejectedAfter.sentAt).toBeNull(); + expect(rejectedAfter.signingStatus).toBe(SigningStatus.REJECTED); + await expectSigningRequestJobCount(rejected.id, 0); + + // The genuinely pending next step is activated instead. + expect(laterAfter.sentAt).not.toBeNull(); + await expectSigningRequestJobCount(later.id, 1); +}); diff --git a/packages/app-tests/e2e/recipient/signing-group-advancement.spec.ts b/packages/app-tests/e2e/recipient/signing-group-advancement.spec.ts new file mode 100644 index 000000000..e5f083506 --- /dev/null +++ b/packages/app-tests/e2e/recipient/signing-group-advancement.spec.ts @@ -0,0 +1,153 @@ +import { completeDocumentWithToken } from '@documenso/lib/server-only/document/complete-document-with-token'; +import { prisma } from '@documenso/prisma'; +import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents'; +import { seedUser } from '@documenso/prisma/seed/users'; +import { expect, test } from '@playwright/test'; +import { DocumentSigningOrder, SendStatus } from '@prisma/client'; + +/** + * A signing group is a set of recipients sharing one `signingOrder`. Two + * server-side guarantees define the feature, and neither was asserted anywhere: + * + * 1. When a step unlocks, *every* member of that step is activated together. + * 2. The next step stays locked until *every* member of the current step has + * signed — one member finishing must not advance the flow. + * + * These drive `completeDocumentWithToken` directly rather than the browser, so + * the side effects (`sendStatus`, `sentAt`, signing-request jobs) can be + * asserted precisely, and without the cost of four UI signing flows. + */ + +const expectSigningRequestJobCount = async (recipientId: number, expected: number) => { + const jobs = await prisma.backgroundJob.findMany({ + where: { + jobId: 'send.signing.requested.email', + payload: { + path: ['recipientId'], + equals: recipientId, + }, + }, + }); + + expect(jobs.length).toBe(expected); +}; + +/** + * Steps: 1 = `first`, 2 = `groupA` + `groupB` (the group), 3 = `last`. + */ +const seedGroupedDocument = async () => { + const { user, team } = await seedUser(); + const { user: firstSigner } = await seedUser(); + const { user: groupASigner } = await seedUser(); + const { user: groupBSigner } = await seedUser(); + const { user: lastSigner } = await seedUser(); + + const { recipients } = await seedPendingDocumentWithFullFields({ + owner: user, + teamId: team.id, + recipients: [firstSigner, groupASigner, groupBSigner, lastSigner], + recipientsCreateOptions: [ + { signingOrder: 1, sendStatus: SendStatus.SENT }, + // The seed marks every recipient SENT by default, but a real SEQUENTIAL + // document leaves later steps NOT_SENT until their step unlocks. Without + // this, `sendStatus` would be meaningless as an "activated" signal. + { signingOrder: 2, sendStatus: SendStatus.NOT_SENT }, + { signingOrder: 2, sendStatus: SendStatus.NOT_SENT }, + { signingOrder: 3, sendStatus: SendStatus.NOT_SENT }, + ], + // No fields, so completion is not blocked by unsigned required fields. + fields: [], + updateDocumentOptions: { + documentMeta: { + upsert: { + create: { signingOrder: DocumentSigningOrder.SEQUENTIAL }, + update: { signingOrder: DocumentSigningOrder.SEQUENTIAL }, + }, + }, + }, + }); + + const findByEmail = (email: string) => { + const recipient = recipients.find((item) => item.email === email); + + if (!recipient) { + throw new Error(`Seeded recipient ${email} not found`); + } + + return recipient; + }; + + return { + first: findByEmail(firstSigner.email), + groupA: findByEmail(groupASigner.email), + groupB: findByEmail(groupBSigner.email), + last: findByEmail(lastSigner.email), + }; +}; + +test('[SIGNING_GROUPS]: unlocking a step activates every member of that step, and only that step', async () => { + const { first, groupA, groupB, last } = await seedGroupedDocument(); + + await completeDocumentWithToken({ + token: first.token, + id: { type: 'envelopeId', id: first.envelopeId }, + }); + + const groupAAfter = await prisma.recipient.findUniqueOrThrow({ where: { id: groupA.id } }); + const groupBAfter = await prisma.recipient.findUniqueOrThrow({ where: { id: groupB.id } }); + const lastAfter = await prisma.recipient.findUniqueOrThrow({ where: { id: last.id } }); + + // Both members of step 2 are activated together. + expect(groupAAfter.sendStatus).toBe(SendStatus.SENT); + expect(groupAAfter.sentAt).not.toBeNull(); + await expectSigningRequestJobCount(groupA.id, 1); + + expect(groupBAfter.sendStatus).toBe(SendStatus.SENT); + expect(groupBAfter.sentAt).not.toBeNull(); + await expectSigningRequestJobCount(groupB.id, 1); + + // Step 3 is not pulled forward with them. + expect(lastAfter.sendStatus).toBe(SendStatus.NOT_SENT); + expect(lastAfter.sentAt).toBeNull(); + await expectSigningRequestJobCount(last.id, 0); +}); + +test('[SIGNING_GROUPS]: the next step stays locked until every member of the group has signed', async () => { + const { first, groupA, groupB, last } = await seedGroupedDocument(); + + await completeDocumentWithToken({ + token: first.token, + id: { type: 'envelopeId', id: first.envelopeId }, + }); + + // Only one of the two group members signs. + await completeDocumentWithToken({ + token: groupA.token, + id: { type: 'envelopeId', id: groupA.envelopeId }, + }); + + const lastWhileGroupPending = await prisma.recipient.findUniqueOrThrow({ + where: { id: last.id }, + }); + + expect(lastWhileGroupPending.sendStatus).toBe(SendStatus.NOT_SENT); + expect(lastWhileGroupPending.sentAt).toBeNull(); + await expectSigningRequestJobCount(last.id, 0); + + // The outstanding peer must not be re-notified by their peer's completion. + await expectSigningRequestJobCount(groupB.id, 1); + + // The final member of the group signs; the flow advances. + await completeDocumentWithToken({ + token: groupB.token, + id: { type: 'envelopeId', id: groupB.envelopeId }, + }); + + const lastAfterGroupComplete = await prisma.recipient.findUniqueOrThrow({ + where: { id: last.id }, + }); + + expect(lastAfterGroupComplete.sendStatus).toBe(SendStatus.SENT); + expect(lastAfterGroupComplete.sentAt).not.toBeNull(); + await expectSigningRequestJobCount(last.id, 1); +}); diff --git a/packages/app-tests/e2e/recipient/signing-groups.spec.ts b/packages/app-tests/e2e/recipient/signing-groups.spec.ts new file mode 100644 index 000000000..9b1ab89c3 --- /dev/null +++ b/packages/app-tests/e2e/recipient/signing-groups.spec.ts @@ -0,0 +1,95 @@ +import { prisma } from '@documenso/prisma'; +import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents'; +import { seedUser } from '@documenso/prisma/seed/users'; +import type { Page } from '@playwright/test'; +import { expect, test } from '@playwright/test'; +import { DocumentSigningOrder, DocumentStatus, FieldType } from '@prisma/client'; + +import { signSignaturePad } from '../fixtures/signature'; + +type SeededRecipient = Awaited>['recipients'][number]; + +const completeSigning = async (page: Page, recipient: SeededRecipient) => { + const signUrl = `/sign/${recipient.token}`; + + await page.goto(signUrl); + await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible(); + + await signSignaturePad(page); + + for (const field of recipient.fields) { + await page.locator(`#field-${field.id}`).getByRole('button').click(); + + if (field.type === FieldType.TEXT) { + await page.locator('#custom-text').fill('TEXT'); + await page.getByRole('button', { name: 'Save' }).click(); + } + + await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true'); + } + + await page.getByRole('button', { name: 'Complete' }).click(); + await page.getByRole('button', { name: 'Sign' }).click(); + await page.waitForURL(`${signUrl}/complete`); +}; + +const expectWaiting = async (page: Page, token: string) => { + await page.goto(`/sign/${token}`); + await page.waitForURL(`/sign/${token}/waiting`); +}; + +test('[SIGNING_GROUPS]: group members sign in any order and gate the next step', async ({ page }) => { + const { user, team } = await seedUser(); + const { user: signer1 } = await seedUser(); + const { user: signer2a } = await seedUser(); + const { user: signer2b } = await seedUser(); + const { user: signer3 } = await seedUser(); + + const { recipients, document } = await seedPendingDocumentWithFullFields({ + owner: user, + teamId: team.id, + recipients: [signer1, signer2a, signer2b, signer3], + recipientsCreateOptions: [{ signingOrder: 1 }, { signingOrder: 2 }, { signingOrder: 2 }, { signingOrder: 3 }], + updateDocumentOptions: { + documentMeta: { + upsert: { + create: { signingOrder: DocumentSigningOrder.SEQUENTIAL }, + update: { signingOrder: DocumentSigningOrder.SEQUENTIAL }, + }, + }, + }, + }); + + const [recipient1, recipient2a, recipient2b, recipient3] = recipients; + + // While step 1 is pending, both group members and step 3 are blocked. + await expectWaiting(page, recipient2a.token); + await expectWaiting(page, recipient2b.token); + await expectWaiting(page, recipient3.token); + + await completeSigning(page, recipient1); + + // The group is now active; step 3 is still blocked. + await expectWaiting(page, recipient3.token); + + // Sign with the SECOND group member first to prove any-order signing. + await completeSigning(page, recipient2b); + + // One group member remains — step 3 stays blocked. + await expectWaiting(page, recipient3.token); + + await completeSigning(page, recipient2a); + + // The whole group is done — step 3 unlocks and completes the document. + await completeSigning(page, recipient3); + + await expect + .poll(async () => { + const envelope = await prisma.envelope.findUniqueOrThrow({ + where: { id: document.id }, + }); + + return envelope.status; + }) + .toBe(DocumentStatus.COMPLETED); +}); diff --git a/packages/app-tests/e2e/templates/direct-template-dictation-groups.spec.ts b/packages/app-tests/e2e/templates/direct-template-dictation-groups.spec.ts new file mode 100644 index 000000000..d2c452300 --- /dev/null +++ b/packages/app-tests/e2e/templates/direct-template-dictation-groups.spec.ts @@ -0,0 +1,166 @@ +import { createDocumentFromDirectTemplate } from '@documenso/lib/server-only/template/create-document-from-direct-template'; +import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; +import { prisma } from '@documenso/prisma'; +import { seedDirectTemplate } from '@documenso/prisma/seed/templates'; +import { seedUser } from '@documenso/prisma/seed/users'; +import { expect, test } from '@playwright/test'; +import { DocumentSigningOrder, FieldType, RecipientRole } from '@prisma/client'; + +/** + * "Dictate next signer" lets the signer choose who acts in the NEXT step. With + * signing groups the direct recipient can share a step with someone else, and + * because the direct recipient is created as SIGNED before the pending query + * runs, that same-step peer would otherwise look like the "next" recipient. + * + * The UI never offers dictation in that case, so this exercises the server + * directly — the only way the gap is reachable. + */ + +const requestMetadata: ApiRequestMetadata = { + requestMetadata: {}, + source: 'app', + auth: null, +}; + +const PEER_EMAIL = 'peer@documenso.com'; +const PEER_NAME = 'Peer Signer'; +const LATER_EMAIL = 'later@documenso.com'; +const LATER_NAME = 'Later Signer'; + +const DICTATED = { email: 'dictated@documenso.com', name: 'Dictated Signer' }; + +/** + * Seeds a direct template whose direct recipient sits at `directSigningOrder`, + * plus a peer at `peerSigningOrder` and a signer in a strictly later step. + */ +const seedDirectTemplateWithPeer = async (options: { peerSigningOrder: number }) => { + const { user, team } = await seedUser(); + + const template = await seedDirectTemplate({ + title: '[TEST] Direct template dictation', + userId: user.id, + teamId: team.id, + }); + + await prisma.documentMeta.update({ + where: { id: template.documentMetaId }, + data: { + signingOrder: DocumentSigningOrder.SEQUENTIAL, + allowDictateNextSigner: true, + }, + }); + + const envelopeItem = await prisma.envelopeItem.findFirstOrThrow({ + where: { envelopeId: template.id }, + }); + + // Every SIGNER needs a signature field or the direct-template flow rejects + // the template before it reaches the dictation logic. + const createSigner = async (email: string, name: string, signingOrder: number) => { + const recipient = await prisma.recipient.create({ + data: { + envelopeId: template.id, + email, + name, + token: Math.random().toString().slice(2, 12), + role: RecipientRole.SIGNER, + signingOrder, + }, + }); + + await prisma.field.create({ + data: { + envelopeId: template.id, + envelopeItemId: envelopeItem.id, + recipientId: recipient.id, + type: FieldType.SIGNATURE, + page: 1, + positionX: 5, + positionY: 20 + signingOrder * 5, + width: 20, + height: 5, + customText: '', + inserted: false, + }, + }); + + return recipient; + }; + + const peer = await createSigner(PEER_EMAIL, PEER_NAME, options.peerSigningOrder); + const later = await createSigner(LATER_EMAIL, LATER_NAME, 2); + + const directRecipient = template.recipients.find((recipient) => recipient.signingOrder === 1); + const directSignatureField = template.fields.find((field) => field.type === FieldType.SIGNATURE); + + if (!directRecipient || !directSignatureField) { + throw new Error('Seeded direct template is missing its recipient or signature field'); + } + + // Read updatedAt last: the writes above bump it, and the flow rejects a stale value. + const refreshed = await prisma.envelope.findFirstOrThrow({ where: { id: template.id } }); + + return { + directLinkToken: template.directLink?.token ?? '', + directSignatureFieldId: directSignatureField.id, + templateUpdatedAt: refreshed.updatedAt, + peer, + later, + }; +}; + +const signDirectTemplate = async (seeded: Awaited>) => + await createDocumentFromDirectTemplate({ + directRecipientName: 'Direct Signer', + directRecipientEmail: 'direct-signer@documenso.com', + directTemplateToken: seeded.directLinkToken, + templateUpdatedAt: seeded.templateUpdatedAt, + signedFieldValues: [ + { + token: seeded.directLinkToken, + fieldId: seeded.directSignatureFieldId, + value: 'Direct Signer', + isBase64: false, + }, + ], + nextSigner: DICTATED, + requestMetadata, + }); + +test('[DIRECT_TEMPLATE_DICTATION]: does not dictate a recipient sharing the direct recipient step', async () => { + const seeded = await seedDirectTemplateWithPeer({ peerSigningOrder: 1 }); + + const { envelopeId } = await signDirectTemplate(seeded); + + const recipients = await prisma.recipient.findMany({ where: { envelopeId } }); + + const peer = recipients.find((recipient) => recipient.email === PEER_EMAIL); + const dictated = recipients.find((recipient) => recipient.email === DICTATED.email); + + // The same-step peer must be untouched... + expect(peer).toBeDefined(); + expect(peer?.name).toBe(PEER_NAME); + expect(peer?.signingOrder).toBe(1); + + // ...and nobody at all should have been renamed, since the next step is not reachable yet. + expect(dictated).toBeUndefined(); +}); + +test('[DIRECT_TEMPLATE_DICTATION]: still dictates the next step when the direct recipient is alone', async () => { + const seeded = await seedDirectTemplateWithPeer({ peerSigningOrder: 3 }); + + const { envelopeId } = await signDirectTemplate(seeded); + + const recipients = await prisma.recipient.findMany({ where: { envelopeId } }); + + // The order-2 signer is the sole member of the next step, so dictation applies. + const dictated = recipients.find((recipient) => recipient.email === DICTATED.email); + + expect(dictated).toBeDefined(); + expect(dictated?.name).toBe(DICTATED.name); + expect(dictated?.signingOrder).toBe(2); + + // The untouched recipients keep their seeded identities. + expect(recipients.some((recipient) => recipient.email === LATER_EMAIL)).toBe(false); + expect(recipients.some((recipient) => recipient.email === PEER_EMAIL)).toBe(true); +}); diff --git a/packages/lib/client-only/hooks/use-editor-recipients.ts b/packages/lib/client-only/hooks/use-editor-recipients.ts index b1fce27e0..d324a24d1 100644 --- a/packages/lib/client-only/hooks/use-editor-recipients.ts +++ b/packages/lib/client-only/hooks/use-editor-recipients.ts @@ -9,7 +9,8 @@ import type { UseFormReturn } from 'react-hook-form'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; -import { isCcRecipient, normalizeRecipientSigningOrders, sortRecipientsForSigningOrder } from '../../utils/recipients'; +import { normalizeGroupedSigningOrders } from '../../utils/recipient-groups'; +import { isCcRecipient, sortRecipientsForSigningOrder } from '../../utils/recipients'; const LocalRecipientSchema = z.object({ formId: z.string().min(1), @@ -65,10 +66,71 @@ export const ZEditorRecipientsFormSchema = z }); } }); + + const seenSigningOrders = new Set(); + + data.signers.forEach((signer, index) => { + if (signer.role === RecipientRole.CC || typeof signer.signingOrder !== 'number') { + return; + } + + if (seenSigningOrders.has(signer.signingOrder)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'CSC envelopes do not support recipient signing groups.', + path: ['signers', index, 'signingOrder'], + }); + } + + seenSigningOrders.add(signer.signingOrder); + }); }); export type TEditorRecipientsFormSchema = z.infer; +/** + * Replaces the signers array while keeping controlled inputs in sync. + * + * Rows are rendered with stable `formId` keys (required for drag and drop), + * so react-hook-form `Controller`s never remount and their leaf + * subscriptions are NOT re-notified by a root-level array `setValue`. Any + * value that changes while a signer keeps its index (e.g. a role change) + * must be leaf-set first so the controlled input actually re-renders. + */ +export const updateEditorSigners = ( + form: UseFormReturn, + updatedSigners: TEditorRecipientsFormSchema['signers'], +) => { + const previousSigners = form.getValues('signers'); + + updatedSigners.forEach((signer, index) => { + const previousSigner = previousSigners[index]; + + // Only slot-stable signers need leaf notifications — moved signers get a + // new field name and re-subscribe with fresh values on their own. + if (!previousSigner || previousSigner.formId !== signer.formId) { + return; + } + + if (previousSigner.role !== signer.role) { + form.setValue(`signers.${index}.role`, signer.role, { shouldDirty: true }); + } + + if (previousSigner.email !== signer.email) { + form.setValue(`signers.${index}.email`, signer.email, { shouldDirty: true }); + } + + if (previousSigner.name !== signer.name) { + form.setValue(`signers.${index}.name`, signer.name, { shouldDirty: true }); + } + }); + + form.setValue('signers', updatedSigners, { + shouldValidate: true, + shouldDirty: true, + }); +}; + type EditorRecipientsProps = { envelope: TEditorEnvelope; }; @@ -89,19 +151,43 @@ export const useEditorRecipients = ({ envelope }: EditorRecipientsProps): UseEdi const generateDefaultValues = (options?: ResetFormOptions) => { const { recipients, documentMeta } = options ?? {}; - const formRecipients = (recipients || envelope.recipients).map((recipient, index) => ({ + const sourceRecipients = sortRecipientsForSigningOrder(recipients || envelope.recipients); + + // 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 + // real order, and equal orders now mean "same signing step". + let fallbackOrder = sourceRecipients.reduce( + (highest, recipient) => Math.max(highest, recipient.signingOrder ?? 0), + 0, + ); + + const signingOrderByRecipientId = new Map(); + + for (const recipient of sourceRecipients) { + if (isCcRecipient(recipient)) { + signingOrderByRecipientId.set(recipient.id, undefined); + } else if (typeof recipient.signingOrder === 'number') { + signingOrderByRecipientId.set(recipient.id, recipient.signingOrder); + } else { + fallbackOrder += 1; + signingOrderByRecipientId.set(recipient.id, fallbackOrder); + } + } + + const formRecipients = sourceRecipients.map((recipient) => ({ id: recipient.id, formId: String(recipient.id), name: recipient.name, email: recipient.email, role: recipient.role, - signingOrder: isCcRecipient(recipient) ? undefined : (recipient.signingOrder ?? index + 1), + signingOrder: signingOrderByRecipientId.get(recipient.id), actionAuth: ZRecipientAuthOptionsSchema.parse(recipient.authOptions)?.actionAuth ?? undefined, })); const signers: TLocalRecipient[] = formRecipients.length > 0 - ? normalizeRecipientSigningOrders(sortRecipientsForSigningOrder(formRecipients)) + ? normalizeGroupedSigningOrders(formRecipients) : [ { formId: initialId, diff --git a/packages/lib/client-only/providers/envelope-editor-provider.tsx b/packages/lib/client-only/providers/envelope-editor-provider.tsx index ea49f638c..69853ce5f 100644 --- a/packages/lib/client-only/providers/envelope-editor-provider.tsx +++ b/packages/lib/client-only/providers/envelope-editor-provider.tsx @@ -7,7 +7,10 @@ import { } from '@documenso/lib/types/envelope-editor'; import { trpc } from '@documenso/trpc/react'; import type { TSetEnvelopeFieldsResponse } from '@documenso/trpc/server/envelope-router/set-envelope-fields.types'; -import type { TSetEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/set-envelope-recipients.types'; +import type { + TSetEnvelopeRecipientsRequest, + TSetEnvelopeRecipientsResponse, +} from '@documenso/trpc/server/envelope-router/set-envelope-recipients.types'; import type { TUpdateEnvelopeRequest } from '@documenso/trpc/server/envelope-router/update-envelope.types'; import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors'; import { getRecipientColor } from '@documenso/ui/lib/recipient-colors'; @@ -29,6 +32,14 @@ export type EnvelopeEditorStep = 'upload' | 'addFields' | 'preview'; type UpdateEnvelopePayload = Pick; +type SetRecipientsPayload = (TSetEnvelopeRecipientsRequest['recipients'][number] & { + /** + * Stable client-side key for the signer row, echoed to the server as + * `clientId` so newly created recipients can adopt their server-assigned id. + */ + formId?: string; +})[]; + type EnvelopeEditorProviderValue = { editorConfig: EnvelopeEditorConfig; @@ -47,8 +58,8 @@ type EnvelopeEditorProviderValue = { setLocalEnvelope: (localEnvelope: Partial) => void; updateEnvelope: (envelopeUpdates: UpdateEnvelopePayload) => void; updateEnvelopeAsync: (envelopeUpdates: UpdateEnvelopePayload) => Promise; - setRecipientsDebounced: (recipients: TSetEnvelopeRecipientsRequest['recipients']) => void; - setRecipientsAsync: (recipients: TSetEnvelopeRecipientsRequest['recipients']) => Promise; + setRecipientsDebounced: (recipients: SetRecipientsPayload) => void; + setRecipientsAsync: (recipients: SetRecipientsPayload) => Promise; getRecipientColorKey: (recipientId: number) => TRecipientColor; @@ -170,6 +181,41 @@ export const EnvelopeEditorProvider = ({ const externalFlushCallbacksRef = useRef Promise>>(new Map()); const pendingMutationsRef = useRef>>(new Set()); + /** + * Server-assigned ids for signers created during this session, keyed by + * their stable formId. The recipients form only learns real ids on a form + * reset (step navigation), so this map bridges the gap between autosaves. + */ + const recipientIdByFormIdRef = useRef>(new Map()); + + /** + * Merges known server-assigned ids into the outgoing payload. Without + * this, a newly created signer (id-less in the form until the next form + * reset) would be deleted and recreated on every autosave — churning ids, + * signing tokens and the audit log. The formId doubles as the `clientId` + * echoed back by the server. + */ + const withKnownRecipientIds = (localRecipients: SetRecipientsPayload) => + localRecipients.map((recipient) => ({ + ...recipient, + id: recipient.id ?? (recipient.formId ? recipientIdByFormIdRef.current.get(recipient.formId) : undefined), + clientId: recipient.formId, + })); + + /** + * Records the ids of newly created recipients from the save response. A + * ref — rather than writing ids back into the form — is deliberate: the + * response can land mid interaction, and a form write here re-renders the + * signer rows, which breaks an in-progress recipient drag. + */ + const rememberCreatedRecipientIds = (recipients: TSetEnvelopeRecipientsResponse['data']) => { + for (const recipient of recipients) { + if (recipient.clientId) { + recipientIdByFormIdRef.current.set(recipient.clientId, recipient.id); + } + } + }; + const registerExternalFlush = useCallback((key: string, flush: () => Promise) => { externalFlushCallbacksRef.current.set(key, flush); @@ -210,7 +256,7 @@ export const EnvelopeEditorProvider = ({ triggerSave: setRecipientsDebounced, flush: flushSetRecipients, isPending: isRecipientsMutationPending, - } = useEnvelopeAutosave(async (localRecipients: TSetEnvelopeRecipientsRequest['recipients']) => { + } = useEnvelopeAutosave(async (localRecipients: SetRecipientsPayload) => { try { let recipients: TEditorEnvelope['recipients'] = []; @@ -220,10 +266,12 @@ export const EnvelopeEditorProvider = ({ const response = await setRecipientsMutation.mutateAsync({ envelopeId: currentEnvelope.id, envelopeType: currentEnvelope.type, - recipients: localRecipients, + recipients: withKnownRecipientIds(localRecipients), }); recipients = response.data; + + rememberCreatedRecipientIds(response.data); } else { recipients = mapLocalRecipientsToRecipients({ envelope: currentEnvelope, localRecipients }); } @@ -252,7 +300,7 @@ export const EnvelopeEditorProvider = ({ } }, 1000); - const setRecipientsAsync = async (localRecipients: TSetEnvelopeRecipientsRequest['recipients']) => { + const setRecipientsAsync = async (localRecipients: SetRecipientsPayload) => { setRecipientsDebounced(localRecipients); await flushSetRecipients(); }; diff --git a/packages/lib/server-only/document/complete-document-with-token.ts b/packages/lib/server-only/document/complete-document-with-token.ts index f708a6cf4..7db7a0938 100644 --- a/packages/lib/server-only/document/complete-document-with-token.ts +++ b/packages/lib/server-only/document/complete-document-with-token.ts @@ -25,6 +25,7 @@ import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../ import { extractDocumentAuthMethods } from '../../utils/document-auth'; import type { EnvelopeIdOptions } from '../../utils/envelope'; import { mapSecondaryIdToDocumentId, unsafeBuildEnvelopeIdQuery } from '../../utils/envelope'; +import { filterRecipientsInFirstSigningGroup } from '../../utils/recipient-groups'; import { assertRecipientNotExpired } from '../../utils/recipients'; import { getIsRecipientsTurnToSign } from '../recipient/get-is-recipient-turn'; import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; @@ -423,6 +424,7 @@ export const completeDocumentWithToken = async ({ select: { id: true, signingOrder: true, + signingStatus: true, name: true, email: true, role: true, @@ -451,65 +453,87 @@ export const completeDocumentWithToken = async ({ }); if (envelope.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL) { - const [nextRecipient] = pendingRecipients; + // The next group: every pending recipient sharing the lowest pending + // signing order. If the completing recipient's own step is still + // pending (a group peer has not signed yet), the flow does not advance — + // the remaining peers were already activated when their step unlocked. + const nextGroup = filterRecipientsInFirstSigningGroup(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 = nextGroup.every( + (pendingRecipient) => (pendingRecipient.signingOrder ?? Number.MAX_SAFE_INTEGER) > currentRecipientOrder, + ); + + if (nextGroup.length > 0 && hasCompletedCurrentStep) { + // Dictation only applies when advancing to a single-recipient step. + const canDictateNextSigner = + Boolean(nextSigner) && Boolean(envelope.documentMeta?.allowDictateNextSigner) && nextGroup.length === 1; + + await prisma.$transaction(async (tx) => { + if (canDictateNextSigner && nextSigner) { + const [nextRecipient] = nextGroup; + + 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: nextSigner.name, + }, + { + type: RECIPIENT_DIFF_TYPE.EMAIL, + from: nextRecipient.email, + to: nextSigner.email, + }, + ], + }, + }), + }); + } + + for (const nextRecipient of nextGroup) { + 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(), + ...(canDictateNextSigner && nextSigner + ? { + name: nextSigner.name, + email: nextSigner.email, + } + : {}), }, - }), + }); + } + }); + + for (const nextRecipient of nextGroup) { + await jobs.triggerJob({ + name: 'send.signing.requested.email', + payload: { + userId: envelope.userId, + documentId: legacyDocumentId, + recipientId: nextRecipient.id, + requestMetadata, + }, }); } - - 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, - }, - }); + } } } diff --git a/packages/lib/server-only/document/send-document.ts b/packages/lib/server-only/document/send-document.ts index 1715739f7..b5f401b77 100644 --- a/packages/lib/server-only/document/send-document.ts +++ b/packages/lib/server-only/document/send-document.ts @@ -38,6 +38,7 @@ import { isDocumentCompleted } from '../../utils/document'; import { extractDocumentAuthMethods } from '../../utils/document-auth'; import { type EnvelopeIdOptions, mapSecondaryIdToDocumentId } from '../../utils/envelope'; import { toCheckboxCustomText, toRadioCustomText } from '../../utils/fields'; +import { filterRecipientsInFirstSigningGroup, flattenRecipientGroups } from '../../utils/recipient-groups'; import { getRecipientsWithMissingFields, isRecipientEmailValidForSending } from '../../utils/recipients'; import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf'; @@ -147,13 +148,38 @@ export const sendDocument = async ({ id, userId, teamId, sendEmail, requestMetad envelope.documentMeta.signingOrder = DocumentSigningOrder.SEQUENTIAL; } + // Signing groups cannot exist on a TSP envelope: two recipients sharing a + // step sign in parallel, which breaks the per-recipient /ByteRange invariant. + // The schema-layer guard should have caught this at write time; flatten the + // groups here so an envelope that slipped through is still distributable. + if (isTspEnvelope(envelope)) { + const flattenedOrders = flattenRecipientGroups(envelope.recipients); + + if (flattenedOrders.length > 0) { + console.warn( + `[CSC] Coercing ${flattenedOrders.length} grouped recipient(s) to distinct signing orders for ${envelope.signatureLevel} envelope ${envelope.id} at send time. The schema-layer guard should have caught this earlier.`, + ); + + await prisma.$transaction( + flattenedOrders.map(({ id, signingOrder: order }) => + prisma.recipient.update({ where: { id }, data: { signingOrder: order } }), + ), + ); + + envelope.recipients = envelope.recipients.map((recipient) => { + const flattened = flattenedOrders.find((entry) => entry.id === recipient.id); + + return flattened ? { ...recipient, signingOrder: flattened.signingOrder } : recipient; + }); + } + } + let recipientsToNotify = envelope.recipients; if (signingOrder === DocumentSigningOrder.SEQUENTIAL) { - // Get the currently active recipient. - recipientsToNotify = envelope.recipients - .filter((r) => r.signingStatus === SigningStatus.NOT_SIGNED && r.role !== RecipientRole.CC) - .slice(0, 1); + // Get the currently active signing group. Recipients sharing the lowest + // pending signing order act in parallel within their group. + recipientsToNotify = filterRecipientsInFirstSigningGroup(envelope.recipients); } if (envelope.envelopeItems.length === 0) { diff --git a/packages/lib/server-only/envelope/create-envelope.ts b/packages/lib/server-only/envelope/create-envelope.ts index 2bbb079fd..c893e9651 100644 --- a/packages/lib/server-only/envelope/create-envelope.ts +++ b/packages/lib/server-only/envelope/create-envelope.ts @@ -38,7 +38,9 @@ import { createDocumentAuthOptions, createRecipientAuthOptions } from '../../uti import { buildTeamWhereQuery } from '../../utils/teams'; import { incrementDocumentId, incrementTemplateId } from '../envelope/increment-id'; import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits'; +import { assertCompatibleRecipientGrouping } from '../signature-level/assert-compatible-recipient-grouping'; import { assertCompatibleRecipientRole } from '../signature-level/assert-compatible-recipient-role'; +import { assignDefaultRecipientSigningOrders } from '../signature-level/assign-default-recipient-signing-orders'; import { resolveSignatureLevel } from '../signature-level/resolve-signature-level'; import { getTeamSettings } from '../team/get-team-settings'; import { assertUserNotDisabledById } from '../user/assert-user-not-disabled'; @@ -280,6 +282,32 @@ export const createEnvelope = async ({ assertCompatibleRecipientRole({ signatureLevel, role: recipient.role }); } + const parsedDefaultRecipients = + settings.defaultRecipients && !bypassDefaultRecipients + ? ZDefaultRecipientsSchema.parse(settings.defaultRecipients) + : []; + + const defaultRecipients: CreateEnvelopeRecipientOptions[] = parsedDefaultRecipients.map((recipient) => ({ + email: recipient.email, + name: recipient.name, + role: recipient.role, + })); + + // Team default recipients carry no signing order, which on a TSP envelope + // would land them all in the shared tail step — a signing group — so they + // are numbered after the payload's highest order. + const orderedDefaultRecipients = assignDefaultRecipientSigningOrders({ + signatureLevel, + payloadRecipients: data.recipients ?? [], + defaultRecipients, + }); + + const allRecipientsToCreate = [...(data.recipients || []), ...orderedDefaultRecipients]; + + // The grouping assertion runs against the COMBINED set the envelope will + // actually hold, not just the payload. + assertCompatibleRecipientGrouping({ signatureLevel, recipients: allRecipientsToCreate }); + const visibility = visibilityOverride || settings.documentVisibility; const emailId = meta?.emailId; @@ -403,21 +431,8 @@ export const createEnvelope = async ({ const firstEnvelopeItem = envelope.envelopeItems[0]; - const defaultRecipients = - settings.defaultRecipients && !bypassDefaultRecipients - ? ZDefaultRecipientsSchema.parse(settings.defaultRecipients) - : []; - - const mappedDefaultRecipients: CreateEnvelopeRecipientOptions[] = defaultRecipients.map((recipient) => ({ - email: recipient.email, - name: recipient.name, - role: recipient.role, - })); - - const allRecipients = [...(data.recipients || []), ...mappedDefaultRecipients]; - await Promise.all( - allRecipients.map(async (recipient) => { + allRecipientsToCreate.map(async (recipient) => { const recipientAuthOptions = createRecipientAuthOptions({ accessAuth: recipient.accessAuth ?? [], actionAuth: recipient.actionAuth ?? [], diff --git a/packages/lib/server-only/envelope/get-envelope-for-recipient-signing.ts b/packages/lib/server-only/envelope/get-envelope-for-recipient-signing.ts index 116238f3b..e4c93d474 100644 --- a/packages/lib/server-only/envelope/get-envelope-for-recipient-signing.ts +++ b/packages/lib/server-only/envelope/get-envelope-for-recipient-signing.ts @@ -5,13 +5,14 @@ import EnvelopeSchema from '@documenso/prisma/generated/zod/modelSchema/Envelope import SignatureSchema from '@documenso/prisma/generated/zod/modelSchema/SignatureSchema'; import TeamSchema from '@documenso/prisma/generated/zod/modelSchema/TeamSchema'; import UserSchema from '@documenso/prisma/generated/zod/modelSchema/UserSchema'; -import { DocumentSigningOrder, DocumentStatus, EnvelopeType, RecipientRole, SigningStatus } from '@prisma/client'; +import { DocumentSigningOrder, DocumentStatus, EnvelopeType, SigningStatus } from '@prisma/client'; import { z } from 'zod'; import { AppError, AppErrorCode } from '../../errors/app-error'; import type { TDocumentAuthMethods } from '../../types/document-auth'; import { ZEnvelopeFieldSchema, ZFieldSchema } from '../../types/field'; import { ZRecipientLiteSchema } from '../../types/recipient'; +import { isRecipientTurnBySigningOrder } from '../../utils/recipient-groups'; import { isRecipientExpired } from '../../utils/recipients'; import { isRecipientAuthorized } from '../document/is-recipient-authorized'; import { getTeamSettings } from '../team/get-team-settings'; @@ -260,23 +261,9 @@ export const getEnvelopeForRecipientSigning = async ({ }, }); - let isRecipientsTurn = true; - - const currentRecipientIndex = envelope.recipients.findIndex((r) => r.token === token); - - if (envelope.documentMeta.signingOrder === DocumentSigningOrder.SEQUENTIAL && currentRecipientIndex !== -1) { - for (let i = 0; i < currentRecipientIndex; i++) { - // CC recipients have no action to take, so they can never block the flow. - if (envelope.recipients[i].role === RecipientRole.CC) { - continue; - } - - if (envelope.recipients[i].signingStatus !== SigningStatus.SIGNED) { - isRecipientsTurn = false; - break; - } - } - } + const isRecipientsTurn = + envelope.documentMeta.signingOrder !== DocumentSigningOrder.SEQUENTIAL || + isRecipientTurnBySigningOrder(envelope.recipients, recipient); const sender = settings.includeSenderDetails ? { diff --git a/packages/lib/server-only/field/get-fields-for-token.ts b/packages/lib/server-only/field/get-fields-for-token.ts index 8e29ae327..eff45783b 100644 --- a/packages/lib/server-only/field/get-fields-for-token.ts +++ b/packages/lib/server-only/field/get-fields-for-token.ts @@ -1,6 +1,8 @@ import { prisma } from '@documenso/prisma'; import { EnvelopeType, FieldType, RecipientRole, SigningStatus } from '@prisma/client'; +import { getLaterSigningStepRecipientsWhereInput } from '../../utils/recipients'; + export type GetFieldsForTokenOptions = { token: string; }; @@ -31,10 +33,11 @@ export const getFieldsForToken = async ({ token }: GetFieldsForTokenOptions) => signingStatus: { not: SigningStatus.SIGNED, }, - signingOrder: { - gte: recipient.signingOrder ?? 0, - }, envelopeId: recipient.envelopeId, + // Assistants can only assist those in strictly later steps — + // never their own group peers, with null orders as the tail + // step. (Own fields are matched by the sibling OR arm.) + AND: [getLaterSigningStepRecipientsWhereInput(recipient)], }, envelope: { id: recipient.envelopeId, diff --git a/packages/lib/server-only/field/remove-signed-field-with-token.ts b/packages/lib/server-only/field/remove-signed-field-with-token.ts index 5e2c41861..bcf2a6265 100644 --- a/packages/lib/server-only/field/remove-signed-field-with-token.ts +++ b/packages/lib/server-only/field/remove-signed-field-with-token.ts @@ -1,7 +1,7 @@ import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs'; -import { assertRecipientNotExpired } from '@documenso/lib/utils/recipients'; +import { assertRecipientNotExpired, getFieldOwnerWhereInput } from '@documenso/lib/utils/recipients'; import { prisma } from '@documenso/prisma'; import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client'; @@ -25,21 +25,7 @@ export const removeSignedFieldWithToken = async ({ const field = await prisma.field.findFirstOrThrow({ where: { id: fieldId, - recipient: { - ...(recipient.role !== RecipientRole.ASSISTANT - ? { - id: recipient.id, - } - : { - signingOrder: { - gte: recipient.signingOrder ?? 0, - }, - signingStatus: { - not: SigningStatus.SIGNED, - }, - envelopeId: recipient.envelopeId, - }), - }, + recipient: getFieldOwnerWhereInput(recipient), }, include: { envelope: true, diff --git a/packages/lib/server-only/field/sign-field-with-token.ts b/packages/lib/server-only/field/sign-field-with-token.ts index 84412d903..07e0b935e 100644 --- a/packages/lib/server-only/field/sign-field-with-token.ts +++ b/packages/lib/server-only/field/sign-field-with-token.ts @@ -13,6 +13,7 @@ import { match } from 'ts-pattern'; import { AUTO_SIGNABLE_FIELD_TYPES } from '../../constants/autosign'; import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../../constants/date-formats'; import { DEFAULT_DOCUMENT_TIME_ZONE } from '../../constants/time-zones'; +import { AppError, AppErrorCode } from '../../errors/app-error'; import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs'; import type { TRecipientActionAuth } from '../../types/document-auth'; import { @@ -24,7 +25,7 @@ import { } from '../../types/field-meta'; import type { RequestMetadata } from '../../universal/extract-request-metadata'; import { createDocumentAuditLogData } from '../../utils/document-audit-logs'; -import { assertRecipientNotExpired } from '../../utils/recipients'; +import { assertRecipientNotExpired, getFieldOwnerWhereInput } from '../../utils/recipients'; import { validateFieldAuth } from '../document/validate-field-auth'; export type SignFieldWithTokenOptions = { @@ -65,21 +66,7 @@ export const signFieldWithToken = async ({ const field = await prisma.field.findFirstOrThrow({ where: { id: fieldId, - recipient: { - ...(recipient.role !== RecipientRole.ASSISTANT - ? { - id: recipient.id, - } - : { - signingStatus: { - not: SigningStatus.SIGNED, - }, - signingOrder: { - gte: recipient.signingOrder ?? 0, - }, - envelopeId: recipient.envelopeId, - }), - }, + recipient: getFieldOwnerWhereInput(recipient), }, include: { envelope: { @@ -124,6 +111,18 @@ export const signFieldWithToken = async ({ throw new Error(`Field ${fieldId} has no recipientId`); } + // Mirrors the V2 guard in `sign-envelope-field.ts`: assistants may prefill + // other recipients' fields but never their signature fields. + if ( + field.type === FieldType.SIGNATURE && + recipient.role === RecipientRole.ASSISTANT && + field.recipientId !== recipient.id + ) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: 'Assistant recipients cannot sign signature fields', + }); + } + if (field.type === FieldType.NUMBER && field.fieldMeta) { const numberFieldParsedMeta = ZNumberFieldMeta.parse(field.fieldMeta); const errors = validateNumberField(value, numberFieldParsedMeta, true); diff --git a/packages/lib/server-only/recipient/create-envelope-recipients.ts b/packages/lib/server-only/recipient/create-envelope-recipients.ts index 58d5e68bd..01a2a6767 100644 --- a/packages/lib/server-only/recipient/create-envelope-recipients.ts +++ b/packages/lib/server-only/recipient/create-envelope-recipients.ts @@ -12,6 +12,7 @@ import type { EnvelopeIdOptions } from '../../utils/envelope'; import { mapRecipientToLegacyRecipient } from '../../utils/recipients'; import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable'; import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; +import { assertCompatibleRecipientGrouping } from '../signature-level/assert-compatible-recipient-grouping'; import { assertCompatibleRecipientRole } from '../signature-level/assert-compatible-recipient-role'; export interface CreateEnvelopeRecipientsOptions { @@ -91,6 +92,13 @@ export const createEnvelopeRecipients = async ({ }); } + // Grouping is a property of the whole recipient set, so check the state the + // envelope will be left in rather than the incoming batch alone. + assertCompatibleRecipientGrouping({ + signatureLevel: envelope.signatureLevel, + recipients: [...envelope.recipients, ...recipientsToCreate], + }); + const normalizedRecipients = recipientsToCreate.map((recipient) => ({ ...recipient, email: recipient.email.toLowerCase(), diff --git a/packages/lib/server-only/recipient/get-is-recipient-turn.ts b/packages/lib/server-only/recipient/get-is-recipient-turn.ts index fae6e130b..cbbd8020c 100644 --- a/packages/lib/server-only/recipient/get-is-recipient-turn.ts +++ b/packages/lib/server-only/recipient/get-is-recipient-turn.ts @@ -1,5 +1,7 @@ import { prisma } from '@documenso/prisma'; -import { DocumentSigningOrder, EnvelopeType, RecipientRole, SigningStatus } from '@prisma/client'; +import { DocumentSigningOrder, EnvelopeType } from '@prisma/client'; + +import { isRecipientTurnBySigningOrder } from '../../utils/recipient-groups'; export type GetIsRecipientTurnOptions = { token: string; @@ -17,11 +19,7 @@ export async function getIsRecipientsTurnToSign({ token }: GetIsRecipientTurnOpt }, include: { documentMeta: true, - recipients: { - orderBy: { - signingOrder: 'asc', - }, - }, + recipients: true, }, }); @@ -29,24 +27,11 @@ export async function getIsRecipientsTurnToSign({ token }: GetIsRecipientTurnOpt return true; } - const { recipients } = envelope; + const currentRecipient = envelope.recipients.find((recipient) => recipient.token === token); - const currentRecipientIndex = recipients.findIndex((r) => r.token === token); - - if (currentRecipientIndex === -1) { + if (!currentRecipient) { return false; } - for (let i = 0; i < currentRecipientIndex; i++) { - // CC recipients have no action to take, so they can never block the flow. - if (recipients[i].role === RecipientRole.CC) { - continue; - } - - if (recipients[i].signingStatus !== SigningStatus.SIGNED) { - return false; - } - } - - return true; + return isRecipientTurnBySigningOrder(envelope.recipients, currentRecipient); } diff --git a/packages/lib/server-only/recipient/get-next-pending-recipient.ts b/packages/lib/server-only/recipient/get-next-pending-recipient.ts index 30e867691..4bbd365f9 100644 --- a/packages/lib/server-only/recipient/get-next-pending-recipient.ts +++ b/packages/lib/server-only/recipient/get-next-pending-recipient.ts @@ -1,7 +1,8 @@ import { prisma } from '@documenso/prisma'; -import { EnvelopeType, RecipientRole } from '@prisma/client'; +import { EnvelopeType } from '@prisma/client'; import { mapDocumentIdToSecondaryId } from '../../utils/envelope'; +import { getNextDictatableRecipient } from '../../utils/recipient-groups'; export const getNextPendingRecipient = async ({ documentId, @@ -16,33 +17,17 @@ export const getNextPendingRecipient = async ({ type: EnvelopeType.DOCUMENT, secondaryId: mapDocumentIdToSecondaryId(documentId), }, - // CC recipients are informational only and never take part in signing, - // so they must never be offered as the next pending recipient. - role: { - not: RecipientRole.CC, - }, }, - orderBy: [ - { - signingOrder: { - sort: 'asc', - nulls: 'last', - }, - }, - { - id: 'asc', - }, - ], }); - const currentIndex = recipients.findIndex((r) => r.id === currentRecipientId); + const nextRecipient = getNextDictatableRecipient({ recipients, currentRecipientId }); - if (currentIndex === -1 || currentIndex === recipients.length - 1) { + if (!nextRecipient) { return null; } return { - ...recipients[currentIndex + 1], + ...nextRecipient, token: '', }; }; diff --git a/packages/lib/server-only/recipient/get-recipients-for-assistant.ts b/packages/lib/server-only/recipient/get-recipients-for-assistant.ts index fc104d0b0..e70fe31c5 100644 --- a/packages/lib/server-only/recipient/get-recipients-for-assistant.ts +++ b/packages/lib/server-only/recipient/get-recipients-for-assistant.ts @@ -2,6 +2,7 @@ import { prisma } from '@documenso/prisma'; import { FieldType } from '@prisma/client'; import { AppError, AppErrorCode } from '../../errors/app-error'; +import { getAssistableRecipientsWhereInput } from '../../utils/recipients'; export interface GetRecipientsForAssistantOptions { token: string; @@ -23,9 +24,9 @@ export const getRecipientsForAssistant = async ({ token }: GetRecipientsForAssis let recipients = await prisma.recipient.findMany({ where: { envelopeId: assistant.envelopeId, - signingOrder: { - gte: assistant.signingOrder ?? 0, - }, + // The assistant themself plus strictly later steps — never their own + // group peers, with null orders treated as the tail step. + AND: [getAssistableRecipientsWhereInput(assistant)], }, include: { fields: { diff --git a/packages/lib/server-only/recipient/set-document-recipients.ts b/packages/lib/server-only/recipient/set-document-recipients.ts index ca2ca666d..0df229827 100644 --- a/packages/lib/server-only/recipient/set-document-recipients.ts +++ b/packages/lib/server-only/recipient/set-document-recipients.ts @@ -17,6 +17,7 @@ import { type EnvelopeIdOptions, mapSecondaryIdToDocumentId } from '../../utils/ import { canRecipientBeModified, isRecipientEmailValidForSending } from '../../utils/recipients'; import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable'; import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; +import { assertCompatibleRecipientGrouping } from '../signature-level/assert-compatible-recipient-grouping'; import { assertCompatibleRecipientRole } from '../signature-level/assert-compatible-recipient-role'; export interface SetDocumentRecipientsOptions { @@ -98,6 +99,13 @@ export const setDocumentRecipients = async ({ }); } + // This route replaces the whole recipient set, so the payload is the + // resulting state. + assertCompatibleRecipientGrouping({ + signatureLevel: envelope.signatureLevel, + recipients, + }); + const normalizedRecipients = recipients.map((recipient) => ({ ...recipient, email: recipient.email.toLowerCase(), diff --git a/packages/lib/server-only/recipient/set-template-recipients.ts b/packages/lib/server-only/recipient/set-template-recipients.ts index b177f3315..94ce4abd5 100644 --- a/packages/lib/server-only/recipient/set-template-recipients.ts +++ b/packages/lib/server-only/recipient/set-template-recipients.ts @@ -12,6 +12,7 @@ import { nanoid } from '../../universal/id'; import { createRecipientAuthOptions } from '../../utils/document-auth'; import { type EnvelopeIdOptions, mapSecondaryIdToTemplateId } from '../../utils/envelope'; import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; +import { assertCompatibleRecipientGrouping } from '../signature-level/assert-compatible-recipient-grouping'; import { assertCompatibleRecipientRole } from '../signature-level/assert-compatible-recipient-role'; export type SetTemplateRecipientsOptions = { @@ -68,6 +69,13 @@ export const setTemplateRecipients = async ({ userId, teamId, id, recipients }: }); } + // This route replaces the whole recipient set, so the payload is the + // resulting state. + assertCompatibleRecipientGrouping({ + signatureLevel: envelope.signatureLevel, + recipients, + }); + const normalizedRecipients = recipients.map((recipient) => { // Force replace any changes to the name or email of the direct recipient. if (envelope.directLink && recipient.id === envelope.directLink.directTemplateRecipientId) { diff --git a/packages/lib/server-only/recipient/update-envelope-recipients.ts b/packages/lib/server-only/recipient/update-envelope-recipients.ts index 553b30543..bb1ffb9d1 100644 --- a/packages/lib/server-only/recipient/update-envelope-recipients.ts +++ b/packages/lib/server-only/recipient/update-envelope-recipients.ts @@ -14,6 +14,7 @@ import { mapFieldToLegacyField } from '../../utils/fields'; import { canRecipientBeModified } from '../../utils/recipients'; import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable'; import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; +import { assertCompatibleRecipientGrouping } from '../signature-level/assert-compatible-recipient-grouping'; import { assertCompatibleRecipientRole } from '../signature-level/assert-compatible-recipient-role'; export interface UpdateEnvelopeRecipientsOptions { @@ -99,6 +100,17 @@ export const updateEnvelopeRecipients = async ({ }); } + // Grouping is a property of the whole recipient set, so check the state the + // envelope will be left in once these updates are applied. + assertCompatibleRecipientGrouping({ + signatureLevel: envelope.signatureLevel, + recipients: envelope.recipients.map((existingRecipient) => { + const update = recipients.find((recipient) => recipient.id === existingRecipient.id); + + return update ? { ...existingRecipient, ...update } : existingRecipient; + }), + }); + const recipientsToUpdate = recipients.map((recipient) => { const originalRecipient = envelope.recipients.find((existingRecipient) => existingRecipient.id === recipient.id); diff --git a/packages/lib/server-only/signature-level/assert-compatible-recipient-grouping.test.ts b/packages/lib/server-only/signature-level/assert-compatible-recipient-grouping.test.ts new file mode 100644 index 000000000..a832564be --- /dev/null +++ b/packages/lib/server-only/signature-level/assert-compatible-recipient-grouping.test.ts @@ -0,0 +1,80 @@ +import { RecipientRole } from '@prisma/client'; +import { describe, expect, it } from 'vitest'; + +import { SignatureLevel } from '../../types/signature-level'; +import { assertCompatibleRecipientGrouping } from './assert-compatible-recipient-grouping'; + +const signer = (signingOrder: number | null) => ({ role: RecipientRole.SIGNER, signingOrder }); +const cc = (signingOrder: number | null) => ({ role: RecipientRole.CC, signingOrder }); + +const expectRejected = ( + signatureLevel: string, + recipients: Array<{ role: RecipientRole; signingOrder: number | null }>, +) => { + expect(() => assertCompatibleRecipientGrouping({ signatureLevel, recipients })).toThrow( + /signing group|same signing step/i, + ); +}; + +const expectAccepted = ( + signatureLevel: string, + recipients: Array<{ role: RecipientRole; signingOrder: number | null }>, +) => { + expect(() => assertCompatibleRecipientGrouping({ signatureLevel, recipients })).not.toThrow(); +}; + +describe('assertCompatibleRecipientGrouping', () => { + describe('AES/QES envelopes', () => { + for (const signatureLevel of [SignatureLevel.AES, SignatureLevel.QES]) { + it(`rejects two signers sharing a signing order (${signatureLevel})`, () => { + expectRejected(signatureLevel, [signer(1), signer(2), signer(2)]); + }); + + it(`accepts distinct signing orders (${signatureLevel})`, () => { + expectAccepted(signatureLevel, [signer(1), signer(2), signer(3)]); + }); + } + + // Every null order collapses into the same tail step, so two of them sign + // in parallel exactly as a duplicate order would. + it('rejects two signers without a signing order', () => { + expectRejected(SignatureLevel.AES, [signer(null), signer(null)]); + }); + + it('rejects a signer without an order alongside an ordered signer', () => { + // The unordered recipient shares the tail step with the other null. + expectRejected(SignatureLevel.AES, [signer(1), signer(null), signer(null)]); + }); + + it('accepts a single signer without a signing order', () => { + expectAccepted(SignatureLevel.AES, [signer(null)]); + }); + + it('accepts one ordered signer and one unordered signer', () => { + expectAccepted(SignatureLevel.AES, [signer(1), signer(null)]); + }); + + // CC recipients never sign, so their order carries no meaning. + it('ignores CC recipients sharing an order with a signer', () => { + expectAccepted(SignatureLevel.AES, [signer(1), cc(1)]); + }); + + it('ignores several CC recipients sharing an order with each other', () => { + expectAccepted(SignatureLevel.AES, [signer(1), cc(2), cc(2), cc(null), cc(null)]); + }); + + it('accepts an empty recipient list', () => { + expectAccepted(SignatureLevel.AES, []); + }); + }); + + describe('SES envelopes', () => { + it('permits signing groups', () => { + expectAccepted(SignatureLevel.SES, [signer(1), signer(2), signer(2)]); + }); + + it('permits multiple unordered signers', () => { + expectAccepted(SignatureLevel.SES, [signer(null), signer(null)]); + }); + }); +}); diff --git a/packages/lib/server-only/signature-level/assert-compatible-recipient-grouping.ts b/packages/lib/server-only/signature-level/assert-compatible-recipient-grouping.ts new file mode 100644 index 000000000..ff3893ca9 --- /dev/null +++ b/packages/lib/server-only/signature-level/assert-compatible-recipient-grouping.ts @@ -0,0 +1,64 @@ +import type { Recipient } from '@prisma/client'; + +import { AppError, AppErrorCode } from '../../errors/app-error'; +import { isTspEnvelope } from '../../types/signature-level'; +import { effectiveOrder } from '../../utils/recipient-groups'; +import { isCcRecipient } from '../../utils/recipients'; + +type AssertCompatibleRecipientGroupingOptions = { + signatureLevel: string; + recipients: Array & { signingOrder?: number | null }>; +}; + +/** + * Reject recipient signing groups on AES/QES envelopes. + * + * A "group" is two or more signing recipients sharing a signing step, which + * they may then complete in any order — including at the same time. That is + * parallel signing scoped to one step, so it breaks the same per-recipient + * `/ByteRange` invariant that {@link assertCompatibleSigningOrder} exists to + * protect: each TSP signature must cover the exact bytes it was applied to, + * and concurrent incremental updates over one base state cannot all verify. + * + * Note this is not caught by the `signingOrder = PARALLEL` guard: groups are + * expressed as duplicate `Recipient.signingOrder` values on a document whose + * `documentMeta.signingOrder` is SEQUENTIAL. + * + * Recipients sharing a step are detected by {@link effectiveOrder}, so an + * absent signing order counts too — every unordered recipient lands in the + * same tail step and would sign in parallel. + * + * CC recipients are ignored: they never sign, and their signing order carries + * no meaning anywhere else. + * + * SES envelopes pass through unchanged — signing groups are an SES feature. + * + * Schema-layer guard. {@link sendDocument} re-checks at distribution time as a + * defence-in-depth backstop. + */ +export const assertCompatibleRecipientGrouping = ({ + signatureLevel, + recipients, +}: AssertCompatibleRecipientGroupingOptions): void => { + if (!isTspEnvelope({ signatureLevel })) { + return; + } + + const seenOrders = new Set(); + + for (const recipient of recipients) { + if (isCcRecipient(recipient)) { + continue; + } + + const order = effectiveOrder(recipient); + + if (seenOrders.has(order)) { + throw new AppError(AppErrorCode.INVALID_BODY, { + message: `Envelopes signed at '${signatureLevel}' cannot place two recipients in the same signing step — a signing group is parallel signing within one step, which breaks the per-recipient /ByteRange invariant TSP signatures rely on. Give every signing recipient a distinct signingOrder.`, + }); + } + + seenOrders.add(order); + } +}; diff --git a/packages/lib/server-only/signature-level/assign-default-recipient-signing-orders.test.ts b/packages/lib/server-only/signature-level/assign-default-recipient-signing-orders.test.ts new file mode 100644 index 000000000..157204b1c --- /dev/null +++ b/packages/lib/server-only/signature-level/assign-default-recipient-signing-orders.test.ts @@ -0,0 +1,100 @@ +import { RecipientRole } from '@prisma/client'; +import { describe, expect, it } from 'vitest'; + +import { assertCompatibleRecipientGrouping } from './assert-compatible-recipient-grouping'; +import { assignDefaultRecipientSigningOrders } from './assign-default-recipient-signing-orders'; + +const signer = (signingOrder?: number | null) => ({ + role: RecipientRole.SIGNER, + signingOrder, +}); + +const defaultRecipient = (email: string, role: RecipientRole = RecipientRole.SIGNER) => ({ + email, + name: email, + role, +}); + +describe('assignDefaultRecipientSigningOrders', () => { + it('leaves defaults unordered on SES envelopes', () => { + const result = assignDefaultRecipientSigningOrders({ + signatureLevel: 'SES', + payloadRecipients: [signer(1), signer(2)], + defaultRecipients: [defaultRecipient('a@example.com'), defaultRecipient('b@example.com')], + }); + + expect(result.map((recipient) => recipient.signingOrder)).toEqual([undefined, undefined]); + }); + + it.each(['AES', 'QES'])('assigns distinct orders after the payload max on %s envelopes', (signatureLevel) => { + const result = assignDefaultRecipientSigningOrders({ + signatureLevel, + payloadRecipients: [signer(1), signer(4)], + defaultRecipients: [defaultRecipient('a@example.com'), defaultRecipient('b@example.com')], + }); + + expect(result.map((recipient) => recipient.signingOrder)).toEqual([5, 6]); + }); + + it('numbers defaults from 1 when the payload has no numeric orders', () => { + const result = assignDefaultRecipientSigningOrders({ + signatureLevel: 'QES', + payloadRecipients: [], + defaultRecipients: [defaultRecipient('a@example.com'), defaultRecipient('b@example.com')], + }); + + expect(result.map((recipient) => recipient.signingOrder)).toEqual([1, 2]); + }); + + it('skips CC defaults while numbering the rest', () => { + const result = assignDefaultRecipientSigningOrders({ + signatureLevel: 'AES', + payloadRecipients: [signer(2)], + defaultRecipients: [ + defaultRecipient('a@example.com'), + defaultRecipient('cc@example.com', RecipientRole.CC), + defaultRecipient('b@example.com'), + ], + }); + + expect(result.map((recipient) => recipient.signingOrder)).toEqual([3, undefined, 4]); + }); + + it('produces a combined set that satisfies the TSP grouping assertion', () => { + const payloadRecipients = [signer(1), signer(2)]; + + const defaults = assignDefaultRecipientSigningOrders({ + signatureLevel: 'QES', + payloadRecipients, + defaultRecipients: [defaultRecipient('a@example.com'), defaultRecipient('b@example.com')], + }); + + expect(() => + assertCompatibleRecipientGrouping({ + signatureLevel: 'QES', + recipients: [...payloadRecipients, ...defaults], + }), + ).not.toThrow(); + }); + + it('remains assertion-compatible when the payload holds a single unordered recipient', () => { + // The write-path assert permits one unordered recipient (no shared step); + // numbered defaults must not collide with it. + const payloadRecipients = [signer(null)]; + + const defaults = assignDefaultRecipientSigningOrders({ + signatureLevel: 'AES', + payloadRecipients, + defaultRecipients: [defaultRecipient('a@example.com')], + }); + + expect(defaults.map((recipient) => recipient.signingOrder)).toEqual([1]); + + expect(() => + assertCompatibleRecipientGrouping({ + signatureLevel: 'AES', + recipients: [...payloadRecipients, ...defaults], + }), + ).not.toThrow(); + }); +}); diff --git a/packages/lib/server-only/signature-level/assign-default-recipient-signing-orders.ts b/packages/lib/server-only/signature-level/assign-default-recipient-signing-orders.ts new file mode 100644 index 000000000..0dd49a5fc --- /dev/null +++ b/packages/lib/server-only/signature-level/assign-default-recipient-signing-orders.ts @@ -0,0 +1,57 @@ +import type { Recipient } from '@prisma/client'; + +import { isTspEnvelope } from '../../types/signature-level'; +import { isCcRecipient } from '../../utils/recipients'; + +type AssignDefaultRecipientSigningOrdersOptions = { + signatureLevel: string; + /** + * The recipients supplied by the caller, already validated by + * {@link assertCompatibleRecipientGrouping}. + */ + payloadRecipients: Array & { signingOrder?: number | null }>; + /** + * The team default recipients to append. + */ + defaultRecipients: T[]; +}; + +/** + * Assigns distinct signing orders to team default recipients appended to a + * TSP (AES/QES) envelope. + * + * Team defaults carry no signing order, so on a TSP envelope two or more of + * them would share the unordered tail step — a signing group, which TSP + * signatures cannot hold (see {@link assertCompatibleRecipientGrouping}). + * Numbering them after the payload's highest order keeps the combined set + * valid at write time instead of relying on the send-time flatten backstop. + * + * CC defaults are left unordered: they never sign and are ignored by the + * grouping assertion. + * + * SES envelopes pass through unchanged — shared steps are an SES feature. + */ +export const assignDefaultRecipientSigningOrders = >({ + signatureLevel, + payloadRecipients, + defaultRecipients, +}: AssignDefaultRecipientSigningOrdersOptions): Array => { + if (!isTspEnvelope({ signatureLevel })) { + return defaultRecipients; + } + + let nextOrder = + payloadRecipients.reduce((highest, recipient) => Math.max(highest, recipient.signingOrder ?? 0), 0) + 1; + + return defaultRecipients.map((recipient) => { + if (isCcRecipient(recipient)) { + return recipient; + } + + const signingOrder = nextOrder; + + nextOrder += 1; + + return { ...recipient, signingOrder }; + }); +}; 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..5b37b4f23 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 { filterRecipientsInFirstSigningGroup } 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,27 @@ export const createDocumentFromDirectTemplate = async ({ orderBy: [{ signingOrder: { sort: 'asc', nulls: 'last' } }, { id: 'asc' }], }); - const nextRecipient = pendingRecipients[0]; + const nextGroup = filterRecipientsInFirstSigningGroup(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 = nextGroup.every( + (pendingRecipient) => (pendingRecipient.signingOrder ?? Number.MAX_SAFE_INTEGER) > directRecipientOrder, + ); + + // Dictation only applies when the next step is a single recipient. + const nextRecipient = hasCompletedCurrentStep && nextGroup.length === 1 ? nextGroup[0] : null; + + // The guard at the top of this function rejects `nextSigner` unless the + // template enables dictation, and the derived meta carries the flag + // through unchanged — but the audit log and the update must share ONE + // condition regardless, so the trail can never record a rewrite that + // was not applied. + if (nextRecipient && documentMeta.allowDictateNextSigner) { auditLogsToCreate.push( createDocumentAuditLogData({ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED, @@ -730,12 +750,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, }, }); } diff --git a/packages/lib/types/recipient.ts b/packages/lib/types/recipient.ts index 25282846a..b0ab8ade8 100644 --- a/packages/lib/types/recipient.ts +++ b/packages/lib/types/recipient.ts @@ -125,3 +125,21 @@ export type TEnvelopeRecipientLite = z.infer; export const ZRecipientEmailSchema = z.union([z.literal(''), zEmail('Invalid email').trim().toLowerCase().max(254)]); + +/** + * Signing order for a recipient, for use in request schemas. + * + * `Recipient.signingOrder` is an `Int` column and Prisma truncates rather than + * rejects a fraction, so an unconstrained `z.number()` silently rewrites the + * caller's value (1.5 becomes 1) — which, since equal orders mean "same signing + * step", can quietly merge recipients into one group. Orders are also 1-based + * everywhere they are generated, and assistant scoping uses `?? 0` as its + * floor, so zero and negatives are not meaningful. + * + * Response schemas intentionally do not use this: existing rows may hold values + * that predate the constraint, and reads must not fail because of it. + */ +export const ZRecipientSigningOrderSchema = z + .number() + .int('Signing order must be an integer') + .min(1, 'Signing order must be greater than 0'); diff --git a/packages/lib/utils/recipient-groups.test.ts b/packages/lib/utils/recipient-groups.test.ts new file mode 100644 index 000000000..25f629472 --- /dev/null +++ b/packages/lib/utils/recipient-groups.test.ts @@ -0,0 +1,692 @@ +import { RecipientRole, SigningStatus } from '@prisma/client'; +import { describe, expect, it } from 'vitest'; + +import { + extractRecipientToNewStep, + filterRecipientsInFirstSigningGroup, + flattenRecipientGroups, + getNextDictatableRecipient, + groupRecipientsBySigningOrder, + isRecipientTurnBySigningOrder, + mergeSteps, + moveRecipientToStep, + normalizeGroupedSigningOrders, + reorderStep, + ungroupStep, +} from './recipient-groups'; + +describe('groupRecipientsBySigningOrder', () => { + it('groups non-CC recipients sharing a signing order into steps', () => { + const recipients = [ + { formId: 'a', role: RecipientRole.SIGNER, signingOrder: 1 }, + { formId: 'b', role: RecipientRole.SIGNER, signingOrder: 2 }, + { formId: 'c', role: RecipientRole.APPROVER, signingOrder: 2 }, + { formId: 'd', role: RecipientRole.SIGNER, signingOrder: 3 }, + ]; + + const { steps, ccRecipients } = groupRecipientsBySigningOrder(recipients); + + expect(ccRecipients).toEqual([]); + expect(steps.map((step) => step.order)).toEqual([1, 2, 3]); + expect(steps.map((step) => step.members.map((m) => m.formId))).toEqual([['a'], ['b', 'c'], ['d']]); + }); + + it('excludes CC recipients from steps', () => { + const recipients = [ + { formId: 'a', role: RecipientRole.SIGNER, signingOrder: 1 }, + { formId: 'b', role: RecipientRole.CC, signingOrder: undefined }, + ]; + + const { steps, ccRecipients } = groupRecipientsBySigningOrder(recipients); + + expect(steps).toHaveLength(1); + expect(ccRecipients.map((r) => r.formId)).toEqual(['b']); + }); + + it('sorts steps by order regardless of input order and keeps member input order', () => { + const recipients = [ + { formId: 'c', role: RecipientRole.SIGNER, signingOrder: 2 }, + { formId: 'a', role: RecipientRole.SIGNER, signingOrder: 1 }, + { formId: 'b', role: RecipientRole.SIGNER, signingOrder: 2 }, + ]; + + const { steps } = groupRecipientsBySigningOrder(recipients); + + expect(steps.map((step) => step.members.map((m) => m.formId))).toEqual([['a'], ['c', 'b']]); + }); + + it('collects recipients without a signing order into a single tail step', () => { + const recipients = [ + { formId: 'a', role: RecipientRole.SIGNER, signingOrder: 1 }, + { formId: 'b', role: RecipientRole.SIGNER, signingOrder: null }, + { formId: 'c', role: RecipientRole.SIGNER, signingOrder: undefined }, + ]; + + const { steps } = groupRecipientsBySigningOrder(recipients); + + expect(steps).toHaveLength(2); + expect(steps[1].members.map((m) => m.formId)).toEqual(['b', 'c']); + }); +}); + +describe('normalizeGroupedSigningOrders', () => { + it('preserves groups while compacting gaps to dense step numbers', () => { + const recipients = [ + { formId: 'a', role: RecipientRole.SIGNER, signingOrder: 2 }, + { formId: 'b', role: RecipientRole.SIGNER, signingOrder: 5 }, + { formId: 'c', role: RecipientRole.SIGNER, signingOrder: 5 }, + { formId: 'd', role: RecipientRole.SIGNER, signingOrder: 9 }, + ]; + + expect(normalizeGroupedSigningOrders(recipients).map((r) => r.signingOrder)).toEqual([1, 2, 2, 3]); + }); + + it('moves CC recipients to the tail with an undefined signing order', () => { + const recipients = [ + { formId: 'cc', role: RecipientRole.CC, signingOrder: 1 }, + { formId: 'a', role: RecipientRole.SIGNER, signingOrder: 3 }, + { formId: 'b', role: RecipientRole.SIGNER, signingOrder: 3 }, + ]; + + const normalized = normalizeGroupedSigningOrders(recipients); + + expect(normalized.map((r) => r.formId)).toEqual(['a', 'b', 'cc']); + expect(normalized.map((r) => r.signingOrder)).toEqual([1, 1, undefined]); + }); + + it('anchors steps containing locked recipients to their persisted order', () => { + const recipients = [ + { formId: 'locked', role: RecipientRole.SIGNER, signingOrder: 1 }, + { formId: 'a', role: RecipientRole.SIGNER, signingOrder: 4 }, + { formId: 'b', role: RecipientRole.SIGNER, signingOrder: 4 }, + ]; + + const normalized = normalizeGroupedSigningOrders(recipients, (r) => r.formId !== 'locked'); + + expect(normalized.map((r) => [r.formId, r.signingOrder])).toEqual([ + ['locked', 1], + ['a', 2], + ['b', 2], + ]); + }); + + it('never renumbers an editable step onto a locked step number', () => { + const recipients = [ + { formId: 'a', role: RecipientRole.SIGNER, signingOrder: 1 }, + { formId: 'locked', role: RecipientRole.SIGNER, signingOrder: 2 }, + { formId: 'b', role: RecipientRole.SIGNER, signingOrder: 5 }, + ]; + + const normalized = normalizeGroupedSigningOrders(recipients, (r) => r.formId !== 'locked'); + + // 'b' must skip the reserved locked number 2 and take 3, not collide into 2. + expect(normalized.map((r) => [r.formId, r.signingOrder])).toEqual([ + ['a', 1], + ['locked', 2], + ['b', 3], + ]); + }); + + it('keeps a group intact when it contains the locked recipient', () => { + const recipients = [ + { formId: 'locked', role: RecipientRole.SIGNER, signingOrder: 2 }, + { formId: 'peer', role: RecipientRole.SIGNER, signingOrder: 2 }, + { formId: 'a', role: RecipientRole.SIGNER, signingOrder: 7 }, + ]; + + const normalized = normalizeGroupedSigningOrders(recipients, (r) => r.formId !== 'locked'); + + expect(normalized.map((r) => [r.formId, r.signingOrder])).toEqual([ + ['locked', 2], + ['peer', 2], + ['a', 3], + ]); + }); + + // Everything up to and including the last locked step is locked, so those + // orders are persisted values and must survive untouched — even the sparse + // ones an API caller may have created. + it('freezes every step up to and including the last locked step', () => { + const recipients = [ + { formId: 'a', role: RecipientRole.SIGNER, signingOrder: 3 }, + { formId: 'locked', role: RecipientRole.SIGNER, signingOrder: 5 }, + { formId: 'b', role: RecipientRole.SIGNER, signingOrder: 9 }, + ]; + + const normalized = normalizeGroupedSigningOrders(recipients, (r) => r.formId !== 'locked'); + + expect(normalized.map((r) => [r.formId, r.signingOrder])).toEqual([ + ['a', 3], + ['locked', 5], + ['b', 6], + ]); + }); + + it('renumbers the unlocked tail densely from the highest locked order', () => { + const recipients = [ + { formId: 'locked', role: RecipientRole.SIGNER, signingOrder: 4 }, + { formId: 'a', role: RecipientRole.SIGNER, signingOrder: 20 }, + { formId: 'b', role: RecipientRole.SIGNER, signingOrder: 31 }, + ]; + + const normalized = normalizeGroupedSigningOrders(recipients, (r) => r.formId !== 'locked'); + + expect(normalized.map((r) => [r.formId, r.signingOrder])).toEqual([ + ['locked', 4], + ['a', 5], + ['b', 6], + ]); + }); + + it('leaves a locked recipient without a persisted order alone', () => { + const recipients: Array<{ formId: string; role: RecipientRole; signingOrder: number | null }> = [ + { formId: 'locked', role: RecipientRole.SIGNER, signingOrder: null }, + { formId: 'a', role: RecipientRole.SIGNER, signingOrder: null }, + ]; + + // Both share the null tail step, so the whole step is locked. + const normalized = normalizeGroupedSigningOrders(recipients, (r) => r.formId !== 'locked'); + + expect(normalized.map((r) => [r.formId, r.signingOrder])).toEqual([ + ['locked', undefined], + ['a', undefined], + ]); + }); +}); + +const makeSigners = () => [ + { formId: 'a', role: RecipientRole.SIGNER, signingOrder: 1 }, + { formId: 'b', role: RecipientRole.SIGNER, signingOrder: 2 }, + { formId: 'c', role: RecipientRole.SIGNER, signingOrder: 3 }, + { formId: 'd', role: RecipientRole.SIGNER, signingOrder: 4 }, +]; + +const ordersOf = (signers: Array<{ formId: string; signingOrder?: number }>) => + signers.map((signer) => [signer.formId, signer.signingOrder]); + +describe('mergeSteps', () => { + it('merges all members of the source step into the target step', () => { + const merged = mergeSteps(makeSigners(), 2, 1); + + expect(ordersOf(merged)).toEqual([ + ['a', 1], + ['b', 2], + ['c', 2], + ['d', 3], + ]); + }); + + it('merges a whole group into another step', () => { + const signers = [ + { formId: 'a', role: RecipientRole.SIGNER, signingOrder: 1 }, + { formId: 'b', role: RecipientRole.SIGNER, signingOrder: 2 }, + { formId: 'c', role: RecipientRole.SIGNER, signingOrder: 2 }, + { formId: 'd', role: RecipientRole.SIGNER, signingOrder: 3 }, + ]; + + const merged = mergeSteps(signers, 1, 2); + + expect(ordersOf(merged)).toEqual([ + ['a', 1], + ['d', 2], + ['b', 2], + ['c', 2], + ]); + }); + + it('returns the input unchanged for an invalid step index', () => { + const signers = makeSigners(); + + expect(mergeSteps(signers, 7, 1)).toEqual(signers); + }); +}); + +describe('moveRecipientToStep', () => { + it('appends the recipient to the target step members', () => { + const moved = moveRecipientToStep(makeSigners(), 'a', 2); + + expect(ordersOf(moved)).toEqual([ + ['b', 1], + ['c', 2], + ['a', 2], + ['d', 3], + ]); + }); + + it('dissolves a group of two when one member joins another step', () => { + const signers = [ + { formId: 'a', role: RecipientRole.SIGNER, signingOrder: 1 }, + { formId: 'b', role: RecipientRole.SIGNER, signingOrder: 1 }, + { formId: 'c', role: RecipientRole.SIGNER, signingOrder: 2 }, + ]; + + const moved = moveRecipientToStep(signers, 'b', 1); + + expect(ordersOf(moved)).toEqual([ + ['a', 1], + ['c', 2], + ['b', 2], + ]); + }); + + it('is a no-op when the recipient is already a member of the target step', () => { + const signers = makeSigners(); + + expect(ordersOf(moveRecipientToStep(signers, 'b', 1))).toEqual(ordersOf(signers)); + }); +}); + +describe('extractRecipientToNewStep', () => { + it('extracts a group member into its own step at the given gap', () => { + const signers = [ + { formId: 'a', role: RecipientRole.SIGNER, signingOrder: 1 }, + { formId: 'b', role: RecipientRole.SIGNER, signingOrder: 2 }, + { formId: 'c', role: RecipientRole.SIGNER, signingOrder: 2 }, + { formId: 'd', role: RecipientRole.SIGNER, signingOrder: 3 }, + ]; + + // Gap 2 = before the step containing 'd'. + const extracted = extractRecipientToNewStep(signers, 'c', 2); + + expect(ordersOf(extracted)).toEqual([ + ['a', 1], + ['b', 2], + ['c', 3], + ['d', 4], + ]); + }); + + it('extracts to the end for an out-of-bounds gap index', () => { + const signers = [ + { formId: 'a', role: RecipientRole.SIGNER, signingOrder: 1 }, + { formId: 'b', role: RecipientRole.SIGNER, signingOrder: 1 }, + { formId: 'c', role: RecipientRole.SIGNER, signingOrder: 2 }, + ]; + + const extracted = extractRecipientToNewStep(signers, 'a', 99); + + expect(ordersOf(extracted)).toEqual([ + ['b', 1], + ['c', 2], + ['a', 3], + ]); + }); + + it('is a no-op when a solo recipient is dropped into an adjacent gap', () => { + const signers = makeSigners(); + + expect(ordersOf(extractRecipientToNewStep(signers, 'b', 1))).toEqual(ordersOf(signers)); + expect(ordersOf(extractRecipientToNewStep(signers, 'b', 2))).toEqual(ordersOf(signers)); + }); +}); + +describe('reorderStep', () => { + it('moves a whole group to a new position', () => { + const signers = [ + { formId: 'a', role: RecipientRole.SIGNER, signingOrder: 1 }, + { formId: 'b', role: RecipientRole.SIGNER, signingOrder: 2 }, + { formId: 'c', role: RecipientRole.SIGNER, signingOrder: 2 }, + { formId: 'd', role: RecipientRole.SIGNER, signingOrder: 3 }, + ]; + + const reordered = reorderStep(signers, 1, 2); + + expect(ordersOf(reordered)).toEqual([ + ['a', 1], + ['d', 2], + ['b', 3], + ['c', 3], + ]); + }); + + it('keeps a locked step number anchored while others flow around it', () => { + const signers = [ + { formId: 'locked', role: RecipientRole.SIGNER, signingOrder: 1 }, + { formId: 'b', role: RecipientRole.SIGNER, signingOrder: 2 }, + { formId: 'c', role: RecipientRole.SIGNER, signingOrder: 3 }, + ]; + + const reordered = reorderStep(signers, 1, 2, (r) => r.formId !== 'locked'); + + expect(ordersOf(reordered)).toEqual([ + ['locked', 1], + ['c', 2], + ['b', 3], + ]); + }); +}); + +describe('ungroupStep', () => { + it('splits a group into consecutive standalone steps preserving relative order', () => { + const signers = [ + { formId: 'a', role: RecipientRole.SIGNER, signingOrder: 1 }, + { formId: 'b', role: RecipientRole.SIGNER, signingOrder: 2 }, + { formId: 'c', role: RecipientRole.SIGNER, signingOrder: 2 }, + { formId: 'd', role: RecipientRole.SIGNER, signingOrder: 3 }, + ]; + + const ungrouped = ungroupStep(signers, 1); + + expect(ordersOf(ungrouped)).toEqual([ + ['a', 1], + ['b', 2], + ['c', 3], + ['d', 4], + ]); + }); + + it('is a no-op on a step with a single member', () => { + const signers = makeSigners(); + + expect(ordersOf(ungroupStep(signers, 0))).toEqual(ordersOf(signers)); + }); + + // Splitting a locked step would rewrite persisted orders, so it is refused + // rather than attempted. + it('is a no-op on a locked step', () => { + const signers = [ + { formId: 'locked', role: RecipientRole.SIGNER, signingOrder: 1 }, + { formId: 'peer', role: RecipientRole.SIGNER, signingOrder: 1 }, + { formId: 'c', role: RecipientRole.SIGNER, signingOrder: 2 }, + ]; + + const result = ungroupStep(signers, 0, (r) => r.formId !== 'locked'); + + expect(ordersOf(result)).toEqual([ + ['locked', 1], + ['peer', 1], + ['c', 2], + ]); + }); +}); + +describe('locked step guards', () => { + const withLockedHead = () => [ + { formId: 'locked', role: RecipientRole.SIGNER, signingOrder: 1 }, + { formId: 'b', role: RecipientRole.SIGNER, signingOrder: 2 }, + { formId: 'c', role: RecipientRole.SIGNER, signingOrder: 3 }, + ]; + + const canUpdate = (r: { formId: string }) => r.formId !== 'locked'; + + it('reorderStep refuses to move a step into the locked region', () => { + const signers = withLockedHead(); + + expect(ordersOf(reorderStep(signers, 2, 0, canUpdate))).toEqual(ordersOf(signers)); + }); + + it('reorderStep refuses to move a locked step', () => { + const signers = withLockedHead(); + + expect(ordersOf(reorderStep(signers, 0, 2, canUpdate))).toEqual(ordersOf(signers)); + }); + + it('extractRecipientToNewStep refuses to insert into the locked region', () => { + const signers = withLockedHead(); + + expect(ordersOf(extractRecipientToNewStep(signers, 'c', 0, canUpdate))).toEqual(ordersOf(signers)); + }); + + it('mergeSteps refuses to merge into a locked step', () => { + const signers = withLockedHead(); + + expect(ordersOf(mergeSteps(signers, 2, 0, canUpdate))).toEqual(ordersOf(signers)); + }); + + it('moveRecipientToStep refuses to move a recipient into a locked step', () => { + const signers = withLockedHead(); + + expect(ordersOf(moveRecipientToStep(signers, 'c', 0, canUpdate))).toEqual(ordersOf(signers)); + }); + + it('still allows reordering entirely within the unlocked tail', () => { + const signers = withLockedHead(); + + expect(ordersOf(reorderStep(signers, 1, 2, canUpdate))).toEqual([ + ['locked', 1], + ['c', 2], + ['b', 3], + ]); + }); +}); + +describe('flattenRecipientGroups', () => { + const recipient = (id: number, signingOrder: number | null, role: RecipientRole = RecipientRole.SIGNER) => ({ + id, + signingOrder, + role, + }); + + it('returns no changes when every signing recipient already has their own step', () => { + expect(flattenRecipientGroups([recipient(1, 1), recipient(2, 2)])).toEqual([]); + }); + + // Sparse but distinct orders are valid; only a shared step needs repairing. + it('leaves a valid but sparse sequence alone', () => { + expect(flattenRecipientGroups([recipient(1, 1), recipient(2, 5)])).toEqual([]); + }); + + it('splits a shared step while preserving relative order', () => { + const changes = flattenRecipientGroups([recipient(1, 1), recipient(2, 2), recipient(3, 2)]); + + // Only the second member of the shared step has to move. + expect(changes).toEqual([{ id: 3, signingOrder: 3 }]); + }); + + it('gives every unordered recipient a distinct step', () => { + const changes = flattenRecipientGroups([recipient(1, null), recipient(2, null)]); + + expect(changes).toEqual([ + { id: 1, signingOrder: 1 }, + { id: 2, signingOrder: 2 }, + ]); + }); + + it('ignores CC recipients', () => { + expect(flattenRecipientGroups([recipient(1, 1), recipient(2, 1, RecipientRole.CC)])).toEqual([]); + }); + + it('does not mutate the input array order', () => { + const recipients = [recipient(3, 2), recipient(1, 1), recipient(2, 2)]; + + flattenRecipientGroups(recipients); + + expect(recipients.map((r) => r.id)).toEqual([3, 1, 2]); + }); +}); + +describe('isRecipientTurnBySigningOrder', () => { + const recipient = ( + id: number, + signingOrder: number | null, + signingStatus: SigningStatus, + role: RecipientRole = RecipientRole.SIGNER, + ) => ({ id, signingOrder, signingStatus, role }); + + it('allows both members of the active group regardless of member order', () => { + const recipients = [ + recipient(1, 1, SigningStatus.SIGNED), + recipient(2, 2, SigningStatus.NOT_SIGNED), + recipient(3, 2, SigningStatus.NOT_SIGNED), + recipient(4, 3, SigningStatus.NOT_SIGNED), + ]; + + expect(isRecipientTurnBySigningOrder(recipients, recipients[1])).toBe(true); + expect(isRecipientTurnBySigningOrder(recipients, recipients[2])).toBe(true); + expect(isRecipientTurnBySigningOrder(recipients, recipients[3])).toBe(false); + }); + + it('blocks later steps until every group member has signed', () => { + const recipients = [ + recipient(1, 1, SigningStatus.SIGNED), + recipient(2, 2, SigningStatus.SIGNED), + recipient(3, 2, SigningStatus.NOT_SIGNED), + recipient(4, 3, SigningStatus.NOT_SIGNED), + ]; + + expect(isRecipientTurnBySigningOrder(recipients, recipients[3])).toBe(false); + }); + + it('treats a rejected recipient in an earlier step as blocking', () => { + const recipients = [recipient(1, 1, SigningStatus.REJECTED), recipient(2, 2, SigningStatus.NOT_SIGNED)]; + + expect(isRecipientTurnBySigningOrder(recipients, recipients[1])).toBe(false); + }); + + it('ignores CC recipients entirely', () => { + const recipients = [ + recipient(1, 1, SigningStatus.NOT_SIGNED, RecipientRole.CC), + recipient(2, 2, SigningStatus.NOT_SIGNED), + ]; + + expect(isRecipientTurnBySigningOrder(recipients, recipients[1])).toBe(true); + }); + + it('treats recipients without a signing order as a parallel tail group', () => { + const recipients = [ + recipient(1, 1, SigningStatus.SIGNED), + recipient(2, null, SigningStatus.NOT_SIGNED), + recipient(3, null, SigningStatus.NOT_SIGNED), + ]; + + expect(isRecipientTurnBySigningOrder(recipients, recipients[1])).toBe(true); + expect(isRecipientTurnBySigningOrder(recipients, recipients[2])).toBe(true); + }); +}); + +describe('filterRecipientsInFirstSigningGroup', () => { + const candidate = ( + id: number, + signingOrder: number | null, + signingStatus: SigningStatus = SigningStatus.NOT_SIGNED, + role: RecipientRole = RecipientRole.SIGNER, + ) => ({ id, signingOrder, signingStatus, role }); + + it('returns every pending recipient sharing the lowest order', () => { + const recipients = [candidate(3, 2), candidate(4, 2), candidate(5, 3)]; + + expect(filterRecipientsInFirstSigningGroup(recipients).map((r) => r.id)).toEqual([3, 4]); + }); + + it('returns an empty array for no pending recipients', () => { + expect(filterRecipientsInFirstSigningGroup([])).toEqual([]); + }); + + it('excludes recipients that have already signed', () => { + const recipients = [candidate(1, 1, SigningStatus.SIGNED), candidate(2, 2)]; + + expect(filterRecipientsInFirstSigningGroup(recipients).map((r) => r.id)).toEqual([2]); + }); + + // A rejected recipient is not pending: advancing to them would re-activate + // and re-email somebody who declined to sign. + it('excludes rejected recipients', () => { + const recipients = [candidate(1, 1, SigningStatus.REJECTED), candidate(2, 2)]; + + expect(filterRecipientsInFirstSigningGroup(recipients).map((r) => r.id)).toEqual([2]); + }); + + it('excludes CC recipients', () => { + const recipients = [ + candidate(1, 1, SigningStatus.NOT_SIGNED, RecipientRole.CC), + candidate(2, 2, SigningStatus.NOT_SIGNED), + ]; + + expect(filterRecipientsInFirstSigningGroup(recipients).map((r) => r.id)).toEqual([2]); + }); + + it('returns an empty array when every recipient is signed or rejected', () => { + const recipients = [candidate(1, 1, SigningStatus.SIGNED), candidate(2, 2, SigningStatus.REJECTED)]; + + expect(filterRecipientsInFirstSigningGroup(recipients)).toEqual([]); + }); +}); + +describe('getNextDictatableRecipient', () => { + const recipient = ( + id: number, + signingOrder: number | null, + signingStatus: SigningStatus, + role: RecipientRole = RecipientRole.SIGNER, + ) => ({ id, signingOrder, signingStatus, role }); + + it('returns the next recipient when current is last of their step and next step is a single recipient', () => { + const recipients = [ + recipient(1, 1, SigningStatus.SIGNED), + recipient(2, 2, SigningStatus.NOT_SIGNED), + recipient(3, 3, SigningStatus.NOT_SIGNED), + ]; + + expect(getNextDictatableRecipient({ recipients, currentRecipientId: 2 })?.id).toBe(3); + }); + + it('returns null while a group peer is still unsigned', () => { + const recipients = [ + recipient(1, 1, SigningStatus.NOT_SIGNED), + recipient(2, 1, SigningStatus.NOT_SIGNED), + recipient(3, 2, SigningStatus.NOT_SIGNED), + ]; + + expect(getNextDictatableRecipient({ recipients, currentRecipientId: 1 })).toBeNull(); + }); + + it('returns the next single recipient once all group peers signed', () => { + const recipients = [ + recipient(1, 1, SigningStatus.SIGNED), + recipient(2, 1, SigningStatus.NOT_SIGNED), + recipient(3, 2, SigningStatus.NOT_SIGNED), + ]; + + expect(getNextDictatableRecipient({ recipients, currentRecipientId: 2 })?.id).toBe(3); + }); + + it('returns null when the next step is a group', () => { + const recipients = [ + recipient(1, 1, SigningStatus.NOT_SIGNED), + recipient(2, 2, SigningStatus.NOT_SIGNED), + recipient(3, 2, SigningStatus.NOT_SIGNED), + ]; + + expect(getNextDictatableRecipient({ recipients, currentRecipientId: 1 })).toBeNull(); + }); + + it('returns null when there is no later step, for CC targets, or unknown recipients', () => { + const recipients = [ + recipient(1, 1, SigningStatus.NOT_SIGNED), + recipient(2, null, SigningStatus.NOT_SIGNED, RecipientRole.CC), + ]; + + expect(getNextDictatableRecipient({ recipients, currentRecipientId: 1 })).toBeNull(); + expect(getNextDictatableRecipient({ recipients, currentRecipientId: 999 })).toBeNull(); + }); + + // The server picks the next recipient from a pending-only query, so an + // already-signed recipient in a later step must be skipped here too — + // otherwise the dictated name/email is applied to someone else. + it('skips an already-signed recipient sitting in a later step', () => { + const recipients = [ + recipient(1, 1, SigningStatus.NOT_SIGNED), + recipient(2, 2, SigningStatus.SIGNED), + recipient(3, 3, SigningStatus.NOT_SIGNED), + ]; + + expect(getNextDictatableRecipient({ recipients, currentRecipientId: 1 })?.id).toBe(3); + }); + + it('ignores signed members when deciding whether the next step is a group', () => { + const recipients = [ + recipient(1, 1, SigningStatus.NOT_SIGNED), + recipient(2, 2, SigningStatus.SIGNED), + recipient(3, 2, SigningStatus.NOT_SIGNED), + ]; + + // Only one member of step 2 is still pending, which is what the server would rename. + expect(getNextDictatableRecipient({ recipients, currentRecipientId: 1 })?.id).toBe(3); + }); + + it('returns null when every later recipient has signed', () => { + const recipients = [recipient(1, 1, SigningStatus.NOT_SIGNED), recipient(2, 2, SigningStatus.SIGNED)]; + + expect(getNextDictatableRecipient({ recipients, currentRecipientId: 1 })).toBeNull(); + }); +}); diff --git a/packages/lib/utils/recipient-groups.ts b/packages/lib/utils/recipient-groups.ts new file mode 100644 index 000000000..5af73e8d4 --- /dev/null +++ b/packages/lib/utils/recipient-groups.ts @@ -0,0 +1,482 @@ +import type { Recipient } from '@prisma/client'; +import { SigningStatus } from '@prisma/client'; + +import { isCcRecipient } from './recipients'; + +/** + * A recipient "step" is the set of non-CC recipients sharing a signing order. + * A step with 2 or more members is a "signing group": members may act in any + * order among themselves, and the next step only unlocks once every member of + * the group has completed their action. + */ + +type GroupableRecipient = Pick & { + signingOrder?: number | null; +}; + +export type RecipientStep = { + /** + * The signing order shared by all members of the step. + */ + order: number; + members: T[]; +}; + +const UNORDERED = Number.MAX_SAFE_INTEGER; + +/** + * The signing order to sort and group by, treating "no order" as last. + * + * Exported so callers outside this module share one definition of the + * null-as-last convention rather than restating it. + */ +export const effectiveOrder = (recipient: { signingOrder?: number | null }) => recipient.signingOrder ?? UNORDERED; + +/** + * Derives the ordered list of steps from a list of recipients. + * + * - Non-CC recipients sharing a signing order form one step. + * - Recipients without a signing order share a single tail step. + * - CC recipients are returned separately and never belong to a step. + */ +export const groupRecipientsBySigningOrder = (recipients: T[]) => { + const ccRecipients = recipients.filter((recipient) => isCcRecipient(recipient)); + const nonCcRecipients = recipients.filter((recipient) => !isCcRecipient(recipient)); + + const membersByOrder = new Map(); + + for (const recipient of nonCcRecipients) { + const order = effectiveOrder(recipient); + const members = membersByOrder.get(order) ?? []; + + members.push(recipient); + membersByOrder.set(order, members); + } + + const steps: RecipientStep[] = [...membersByOrder.entries()] + .sort(([orderA], [orderB]) => orderA - orderB) + .map(([order, members]) => ({ order, members })); + + return { steps, ccRecipients }; +}; + +/** + * Index of the last step containing a recipient that can no longer be modified, + * or -1 when there is none. + * + * Signing is sequential, so anyone who has already acted sits at or before the + * current step. Everything up to and including that step is therefore treated + * as locked: those signing orders are persisted values the server will not let + * us rewrite. Steps after it can only hold recipients who have not acted, so + * they can be renumbered and reordered freely — no anchoring required. + * + * Stated as "up to and including the last locked step" rather than "the locked + * prefix" deliberately: a locked recipient can appear out of sequence (direct + * templates sign at their template order, field insertion has no turn check, + * and a document can be switched from parallel to sequential mid-flight). This + * form stays correct in those cases, just more conservative. + */ +export const getLastLockedStepIndex = ( + steps: RecipientStep[], + canUpdateRecipient: (recipient: T) => boolean = () => true, +): number => + steps.reduce( + (lastIndex, step, index) => (step.members.some((member) => !canUpdateRecipient(member)) ? index : lastIndex), + -1, + ); + +/** + * Dense-renumbers steps to 1..K while preserving groups (duplicate orders). + * + * Steps containing a locked recipient (per `canUpdateRecipient`) keep the + * locked recipient's persisted order, and editable steps never collide into a + * locked step's number. + * + * CC recipients get an undefined signing order and move to the tail. The + * returned array is re-ordered by step sequence. + */ +export const normalizeGroupedSigningOrders = ( + recipients: T[], + canUpdateRecipient: (recipient: T) => boolean = () => true, +): Array => { + const { steps, ccRecipients } = groupRecipientsBySigningOrder(recipients); + + const lastLockedStepIndex = getLastLockedStepIndex(steps, canUpdateRecipient); + + let nextOrder = 1; + + const normalizedSteps = steps.map((step, index) => { + // Locked steps hold persisted orders. Keep them exactly as they are, even + // when sparse — renumbering them is what the server refuses. + if (index <= lastLockedStepIndex) { + const order = step.order === UNORDERED ? undefined : step.order; + + if (order !== undefined) { + nextOrder = Math.max(nextOrder, order + 1); + } + + return { order, members: step.members }; + } + + const order = nextOrder; + + nextOrder += 1; + + return { order, members: step.members }; + }); + + return [ + ...normalizedSteps.flatMap((step) => step.members.map((member) => ({ ...member, signingOrder: step.order }))), + ...ccRecipients.map((recipient) => ({ ...recipient, signingOrder: undefined })), + ]; +}; + +type EditorRecipient = GroupableRecipient & { formId: string }; + +/** + * Merges all members of the source step into the target step. + */ +export const mergeSteps = ( + recipients: T[], + sourceStepIndex: number, + targetStepIndex: number, + canUpdateRecipient?: (recipient: T) => boolean, +): Array => { + const { steps } = groupRecipientsBySigningOrder(recipients); + + const sourceStep = steps[sourceStepIndex]; + const targetStep = steps[targetStepIndex]; + const lastLockedStepIndex = getLastLockedStepIndex(steps, canUpdateRecipient); + + if ( + !sourceStep || + !targetStep || + sourceStepIndex === targetStepIndex || + sourceStepIndex <= lastLockedStepIndex || + targetStepIndex <= lastLockedStepIndex + ) { + return normalizeGroupedSigningOrders(recipients, canUpdateRecipient); + } + + const sourceFormIds = new Set(sourceStep.members.map((member) => member.formId)); + + // Source members join after the target step's existing members. + const remaining = recipients.filter((recipient) => !sourceFormIds.has(recipient.formId)); + const lastMemberFormId = targetStep.members[targetStep.members.length - 1].formId; + const insertAfterIndex = remaining.findIndex((recipient) => recipient.formId === lastMemberFormId); + + const movedMembers = sourceStep.members.map((member) => ({ ...member, signingOrder: targetStep.order })); + + const updated = [ + ...remaining.slice(0, insertAfterIndex + 1), + ...movedMembers, + ...remaining.slice(insertAfterIndex + 1), + ]; + + return normalizeGroupedSigningOrders(updated, canUpdateRecipient); +}; + +/** + * Moves a single recipient into the target step (joins the group). + */ +export const moveRecipientToStep = ( + recipients: T[], + formId: string, + targetStepIndex: number, + canUpdateRecipient?: (recipient: T) => boolean, +): Array => { + const { steps } = groupRecipientsBySigningOrder(recipients); + + const targetStep = steps[targetStepIndex]; + const mover = recipients.find((recipient) => recipient.formId === formId); + const lastLockedStepIndex = getLastLockedStepIndex(steps, canUpdateRecipient); + const moverStepIndex = steps.findIndex((step) => step.members.some((member) => member.formId === formId)); + + if (!targetStep || !mover || isCcRecipient(mover)) { + return normalizeGroupedSigningOrders(recipients, canUpdateRecipient); + } + + // Neither the recipient nor the destination may sit in the locked region. + if (targetStepIndex <= lastLockedStepIndex || moverStepIndex <= lastLockedStepIndex) { + return normalizeGroupedSigningOrders(recipients, canUpdateRecipient); + } + + if (targetStep.members.some((member) => member.formId === formId)) { + return normalizeGroupedSigningOrders(recipients, canUpdateRecipient); + } + + const remaining = recipients.filter((recipient) => recipient.formId !== formId); + const lastMemberFormId = targetStep.members[targetStep.members.length - 1].formId; + const insertAfterIndex = remaining.findIndex((recipient) => recipient.formId === lastMemberFormId); + + const updated = [ + ...remaining.slice(0, insertAfterIndex + 1), + { ...mover, signingOrder: targetStep.order }, + ...remaining.slice(insertAfterIndex + 1), + ]; + + return normalizeGroupedSigningOrders(updated, canUpdateRecipient); +}; + +/** + * Extracts a recipient into its own standalone step at the given gap position + * (gap N sits before step N; an out-of-bounds gap appends to the end). + */ +export const extractRecipientToNewStep = ( + recipients: T[], + formId: string, + insertStepIndex: number, + canUpdateRecipient?: (recipient: T) => boolean, +): Array => { + const { steps } = groupRecipientsBySigningOrder(recipients); + + const mover = recipients.find((recipient) => recipient.formId === formId); + + if (!mover || isCcRecipient(mover)) { + return normalizeGroupedSigningOrders(recipients, canUpdateRecipient); + } + + const currentStepIndex = steps.findIndex((step) => step.members.some((member) => member.formId === formId)); + const isSoloStep = currentStepIndex !== -1 && steps[currentStepIndex].members.length === 1; + const lastLockedStepIndex = getLastLockedStepIndex(steps, canUpdateRecipient); + + // Dropping a solo step into the gap directly above or below itself is a no-op. + if (isSoloStep && (insertStepIndex === currentStepIndex || insertStepIndex === currentStepIndex + 1)) { + return normalizeGroupedSigningOrders(recipients, canUpdateRecipient); + } + + // Gap N sits before step N, so inserting at or before the last locked step + // would land the recipient inside the locked region. + if (insertStepIndex <= lastLockedStepIndex || currentStepIndex <= lastLockedStepIndex) { + return normalizeGroupedSigningOrders(recipients, canUpdateRecipient); + } + + const insertOrder = + insertStepIndex >= steps.length ? (steps[steps.length - 1]?.order ?? 0) + 1 : steps[insertStepIndex].order - 0.5; + + const updated = recipients.map((recipient) => + recipient.formId === formId ? { ...recipient, signingOrder: insertOrder } : recipient, + ); + + return normalizeGroupedSigningOrders(updated, canUpdateRecipient); +}; + +/** + * Moves a whole step (group) to a new position in the step sequence. + * + * Refused when either end sits in the locked region (see + * `getLastLockedStepIndex`); only the unlocked tail can be rearranged. + */ +export const reorderStep = ( + recipients: T[], + fromStepIndex: number, + toStepIndex: number, + canUpdateRecipient: (recipient: T) => boolean = () => true, +): Array => { + const { steps, ccRecipients } = groupRecipientsBySigningOrder(recipients); + + const lastLockedStepIndex = getLastLockedStepIndex(steps, canUpdateRecipient); + + if ( + !steps[fromStepIndex] || + fromStepIndex === toStepIndex || + fromStepIndex <= lastLockedStepIndex || + toStepIndex <= lastLockedStepIndex + ) { + return normalizeGroupedSigningOrders(recipients, canUpdateRecipient); + } + + const reorderedSteps = [...steps]; + const [movedStep] = reorderedSteps.splice(fromStepIndex, 1); + + reorderedSteps.splice(Math.min(toStepIndex, reorderedSteps.length), 0, movedStep); + + // Locked steps cannot be the source or destination, so they keep both their + // position and their persisted order. The moved tail is numbered above the + // highest locked order so it still sorts after them. + const highestLockedOrder = reorderedSteps + .slice(0, lastLockedStepIndex + 1) + .reduce((highest, step) => (step.order === UNORDERED ? highest : Math.max(highest, step.order)), 0); + + const updated = [ + ...reorderedSteps.flatMap((step, index) => { + if (index <= lastLockedStepIndex) { + return step.members; + } + + const order = highestLockedOrder + (index - lastLockedStepIndex); + + return step.members.map((member) => ({ ...member, signingOrder: order })); + }), + ...ccRecipients, + ]; + + return normalizeGroupedSigningOrders(updated, canUpdateRecipient); +}; + +/** + * The signing order changes needed to give every signing recipient a step of + * their own, or an empty array when none share one. + * + * Used to repair an envelope that must not contain signing groups (AES/QES). + * Recipients keep their relative sequence — ordered by signing order, ties + * broken by id, matching how the server sorts them everywhere else — and are + * renumbered densely from 1. Orders are only rewritten when a step is actually + * shared, so a valid-but-sparse sequence is left alone. + * + * CC recipients are excluded: they never sign and carry no step. + */ +export const flattenRecipientGroups = & { signingOrder?: number | null }>( + recipients: T[], +): Array<{ id: number; signingOrder: number }> => { + const signingRecipients = recipients + .filter((recipient) => !isCcRecipient(recipient)) + .sort((a, b) => effectiveOrder(a) - effectiveOrder(b) || a.id - b.id); + + const sharesAStep = new Set(signingRecipients.map(effectiveOrder)).size !== signingRecipients.length; + + if (!sharesAStep) { + return []; + } + + const changes: Array<{ id: number; signingOrder: number }> = []; + + signingRecipients.forEach((recipient, index) => { + const signingOrder = index + 1; + + if (recipient.signingOrder !== signingOrder) { + changes.push({ id: recipient.id, signingOrder }); + } + }); + + return changes; +}; + +type SignableRecipient = Pick & { + signingOrder?: number | null; +}; + +/** + * Whether it is the recipient's turn to act under SEQUENTIAL signing. + * + * A recipient may act iff no non-CC recipient with a strictly lower signing + * order is still unsigned (rejected counts as unsigned/blocking). Recipients + * sharing a signing order never block each other. + * + * Callers are responsible for checking the document is in SEQUENTIAL mode. + */ +export const isRecipientTurnBySigningOrder = ( + recipients: T[], + currentRecipient: { signingOrder?: number | null }, +): boolean => { + const currentOrder = effectiveOrder(currentRecipient); + + return !recipients.some( + (recipient) => + !isCcRecipient(recipient) && + recipient.signingStatus !== SigningStatus.SIGNED && + effectiveOrder(recipient) < currentOrder, + ); +}; + +/** + * Returns every pending recipient sharing the lowest pending signing order — + * the "active group". + * + * Pass the full recipient list: filtering happens here so every caller agrees + * on what "pending" means. A recipient is pending when they are not a CC and + * have not signed OR rejected — advancing to a rejected recipient would + * re-activate and re-email somebody who declined to sign. + */ +export const filterRecipientsInFirstSigningGroup = (recipients: T[]): T[] => { + const pendingRecipients = recipients.filter( + (recipient) => !isCcRecipient(recipient) && recipient.signingStatus === SigningStatus.NOT_SIGNED, + ); + + if (pendingRecipients.length === 0) { + return []; + } + + const minOrder = Math.min(...pendingRecipients.map((recipient) => effectiveOrder(recipient))); + + return pendingRecipients.filter((recipient) => effectiveOrder(recipient) === minOrder); +}; + +/** + * The single recipient that the current recipient may dictate (rename) on + * completion, or null when dictation does not apply: + * + * - the current recipient must be the last unsigned member of their step, and + * - the next step must contain exactly one recipient. + */ +export const getNextDictatableRecipient = >({ + recipients, + currentRecipientId, +}: { + recipients: T[]; + currentRecipientId: number; +}): T | null => { + const currentRecipient = recipients.find((recipient) => recipient.id === currentRecipientId); + + if (!currentRecipient || isCcRecipient(currentRecipient)) { + return null; + } + + const currentOrder = effectiveOrder(currentRecipient); + + const hasUnsignedPeers = recipients.some( + (recipient) => + recipient.id !== currentRecipientId && + !isCcRecipient(recipient) && + effectiveOrder(recipient) === currentOrder && + recipient.signingStatus !== SigningStatus.SIGNED, + ); + + if (hasUnsignedPeers) { + return null; + } + + // Only the step matters here; `filterRecipientsInFirstSigningGroup` drops + // CCs and anyone who has already signed or rejected. + const laterRecipients = recipients.filter((recipient) => effectiveOrder(recipient) > currentOrder); + + const nextStep = filterRecipientsInFirstSigningGroup(laterRecipients); + + if (nextStep.length !== 1) { + return null; + } + + return nextStep[0]; +}; + +/** + * Dissolves a group into consecutive standalone steps preserving relative order. + */ +export const ungroupStep = ( + recipients: T[], + stepIndex: number, + canUpdateRecipient?: (recipient: T) => boolean, +): Array => { + const { steps } = groupRecipientsBySigningOrder(recipients); + + const step = steps[stepIndex]; + + // Splitting a locked step would rewrite persisted orders. + if (!step || step.members.length < 2 || stepIndex <= getLastLockedStepIndex(steps, canUpdateRecipient)) { + return normalizeGroupedSigningOrders(recipients, canUpdateRecipient); + } + + const offsetByFormId = new Map(step.members.map((member, index) => [member.formId, index])); + + const updated = recipients.map((recipient) => { + const offset = offsetByFormId.get(recipient.formId); + + if (offset === undefined) { + return recipient; + } + + return { ...recipient, signingOrder: step.order + offset / (step.members.length + 1) }; + }); + + return normalizeGroupedSigningOrders(updated, canUpdateRecipient); +}; diff --git a/packages/lib/utils/recipients.test.ts b/packages/lib/utils/recipients.test.ts index 5d969f0ff..d76f262ee 100644 --- a/packages/lib/utils/recipients.test.ts +++ b/packages/lib/utils/recipients.test.ts @@ -51,6 +51,24 @@ describe('recipient signing order helpers', () => { ]); }); + it('detects an assistant anywhere in the last signing step (groups)', () => { + expect( + isAssistantLastSigner([ + { role: RecipientRole.SIGNER, signingOrder: 1 }, + { role: RecipientRole.ASSISTANT, signingOrder: 2 }, + { role: RecipientRole.SIGNER, signingOrder: 2 }, + ]), + ).toBe(true); + + expect( + isAssistantLastSigner([ + { role: RecipientRole.ASSISTANT, signingOrder: 1 }, + { role: RecipientRole.SIGNER, signingOrder: 1 }, + { role: RecipientRole.SIGNER, signingOrder: 2 }, + ]), + ).toBe(false); + }); + it('checks whether the last non-CC recipient is an assistant', () => { expect( isAssistantLastSigner([ diff --git a/packages/lib/utils/recipients.ts b/packages/lib/utils/recipients.ts index f51d02c78..b75f621c9 100644 --- a/packages/lib/utils/recipients.ts +++ b/packages/lib/utils/recipients.ts @@ -1,9 +1,10 @@ import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field'; -import type { Envelope, Field, Recipient } from '@prisma/client'; -import { RecipientRole, SigningStatus } from '@prisma/client'; +import type { Envelope, Field, Prisma, Recipient } from '@prisma/client'; +import { EnvelopeType, RecipientRole, SigningStatus } from '@prisma/client'; import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app'; import { AppError, AppErrorCode } from '../errors/app-error'; +import type { TEditorEnvelope } from '../types/envelope-editor'; import type { TRecipientLite } from '../types/recipient'; import { extractLegacyIds } from '../universal/id'; import { zEmail } from './zod'; @@ -22,11 +23,100 @@ export const isCcRecipient = (recipient: Pick) => { return recipient.role === RecipientRole.CC; }; -export const isAssistantLastSigner = (recipients: Pick[]) => { +/** + * Whether an assistant sits in the last signing step (nobody after them to assist). + * + * Falls back to a positional check when no recipient carries a signing order. + */ +export const isAssistantLastSigner = ( + recipients: Array & { signingOrder?: number | null }>, +) => { const nonCcRecipients = recipients.filter((recipient) => !isCcRecipient(recipient)); - const lastNonCcRecipient = nonCcRecipients[nonCcRecipients.length - 1]; - return lastNonCcRecipient?.role === RecipientRole.ASSISTANT; + if (nonCcRecipients.length === 0) { + return false; + } + + const hasAnySigningOrder = nonCcRecipients.some((recipient) => typeof recipient.signingOrder === 'number'); + + if (!hasAnySigningOrder) { + return nonCcRecipients[nonCcRecipients.length - 1]?.role === RecipientRole.ASSISTANT; + } + + const maxOrder = Math.max(...nonCcRecipients.map((recipient) => recipient.signingOrder ?? Number.MAX_SAFE_INTEGER)); + + return nonCcRecipients.some( + (recipient) => + (recipient.signingOrder ?? Number.MAX_SAFE_INTEGER) === maxOrder && recipient.role === RecipientRole.ASSISTANT, + ); +}; + +/** + * Prisma `where` input matching recipients in strictly LATER signing steps + * than the assistant, under the null-as-last convention shared with + * `effectiveOrder`: a missing signing order means the LAST step. + * + * - An assistant with a numeric order reaches recipients with a greater + * numeric order, plus null-order recipients (the tail step). + * - A null-order assistant sits in the last step themselves: nobody comes + * after them, so this matches nothing. + * + * The historical inline filters used `signingOrder ?? 0`, which encoded + * "null = first" — hiding tail-step recipients from ordered assistants and + * letting a null-order assistant reach every ordered (earlier) recipient. + * + * Deliberately applied regardless of the document's signing order mode: + * restricting assistants by step on PARALLEL documents mirrors the + * historical behavior and fails closed. Whether parallel documents should + * lift the restriction entirely is an open product decision. + */ +export const getLaterSigningStepRecipientsWhereInput = ( + assistant: Pick, +): Prisma.RecipientWhereInput => { + if (assistant.signingOrder === null) { + // Matches nothing. + return { id: { in: [] } }; + } + + return { + OR: [{ signingOrder: { gt: assistant.signingOrder } }, { signingOrder: null }], + }; +}; + +/** + * Prisma `where` input matching every recipient an assistant may act for: + * themself, plus recipients in strictly later steps — never their own group + * peers. + */ +export const getAssistableRecipientsWhereInput = ( + assistant: Pick, +): Prisma.RecipientWhereInput => ({ + OR: [{ id: assistant.id }, getLaterSigningStepRecipientsWhereInput(assistant)], +}); + +/** + * Prisma `where` input matching the recipients whose fields the token holder + * may act on: non-assistants may only act on their own fields, while + * assistants may also act on fields of unsigned recipients in strictly later + * steps. + * + * Shared by every field-level endpoint (sign / uninsert, V1 and V2) so the + * scoping rule cannot drift between them. + */ +export const getFieldOwnerWhereInput = ( + recipient: Pick, +): Prisma.RecipientWhereInput => { + if (recipient.role !== RecipientRole.ASSISTANT) { + return { id: recipient.id }; + } + + return { + signingStatus: { + not: SigningStatus.SIGNED, + }, + envelopeId: recipient.envelopeId, + AND: [getAssistableRecipientsWhereInput(recipient)], + }; }; export const sortRecipientsForSigningOrder = (recipients: T[]): T[] => { @@ -120,6 +210,36 @@ export const canRecipientBeModified = ( return true; }; +/** + * Editor-level wrapper around `canRecipientBeModified`. + * + * Template recipients and unsaved (id-less) recipients can always be modified. + */ +export const canEditorRecipientBeModified = ( + envelope: Pick, + recipientId?: number, +) => { + if (envelope.type === EnvelopeType.TEMPLATE) { + return true; + } + + if (recipientId === undefined) { + return true; + } + + const recipient = envelope.recipients.find((r) => r.id === recipientId); + + // The envelope lags behind the form: a recipient the editor has just created + // is not in it yet. Such a recipient cannot have acted on the document, so + // treat an unknown id as modifiable — reporting it as locked would freeze + // reordering for a document nobody has signed. + if (!recipient) { + return true; + } + + return canRecipientBeModified(recipient, envelope.fields); +}; + /** * Whether a recipient can have their fields modified by the document owner. * diff --git a/packages/trpc/server/embedding-router/create-embedding-document.types.ts b/packages/trpc/server/embedding-router/create-embedding-document.types.ts index bdbdd1436..0c15f08b3 100644 --- a/packages/trpc/server/embedding-router/create-embedding-document.types.ts +++ b/packages/trpc/server/embedding-router/create-embedding-document.types.ts @@ -19,6 +19,7 @@ import { ZFieldWidthSchema, } from '@documenso/lib/types/field'; import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta'; +import { ZRecipientSigningOrderSchema } from '@documenso/lib/types/recipient'; import { zEmail } from '@documenso/lib/utils/zod'; import { RecipientRole } from '@documenso/prisma/client'; import { DocumentSigningOrder } from '@documenso/prisma/generated/types'; @@ -36,7 +37,7 @@ export const ZCreateEmbeddingDocumentRequestSchema = z.object({ email: zEmail(), name: z.string(), role: z.nativeEnum(RecipientRole), - signingOrder: z.number().optional(), + signingOrder: ZRecipientSigningOrderSchema.optional(), // We have an any cast so any changes here you need to update it in the embeding document edit page // Search: "map" to find it fields: ZFieldAndMetaSchema.and( diff --git a/packages/trpc/server/embedding-router/create-embedding-template.types.ts b/packages/trpc/server/embedding-router/create-embedding-template.types.ts index 8bacaacf0..05d50b7f3 100644 --- a/packages/trpc/server/embedding-router/create-embedding-template.types.ts +++ b/packages/trpc/server/embedding-router/create-embedding-template.types.ts @@ -19,7 +19,7 @@ import { ZFieldWidthSchema, } from '@documenso/lib/types/field'; import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta'; -import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient'; +import { ZRecipientEmailSchema, ZRecipientSigningOrderSchema } from '@documenso/lib/types/recipient'; import { DocumentSigningOrder, RecipientRole } from '@prisma/client'; import { z } from 'zod'; @@ -33,7 +33,7 @@ export const ZCreateEmbeddingTemplateRequestSchema = z.object({ email: ZRecipientEmailSchema, name: z.string(), role: z.nativeEnum(RecipientRole), - signingOrder: z.number().optional(), + signingOrder: ZRecipientSigningOrderSchema.optional(), // We have an any cast so any changes here you need to update it in the embeding document edit page // Search: "map" to find it fields: ZFieldAndMetaSchema.and( diff --git a/packages/trpc/server/embedding-router/update-embedding-document.types.ts b/packages/trpc/server/embedding-router/update-embedding-document.types.ts index 753ab7224..238499c1c 100644 --- a/packages/trpc/server/embedding-router/update-embedding-document.types.ts +++ b/packages/trpc/server/embedding-router/update-embedding-document.types.ts @@ -19,6 +19,7 @@ import { ZFieldWidthSchema, } from '@documenso/lib/types/field'; import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta'; +import { ZRecipientSigningOrderSchema } from '@documenso/lib/types/recipient'; import { zEmail } from '@documenso/lib/utils/zod'; import { DocumentSigningOrder, RecipientRole } from '@documenso/prisma/generated/types'; import { z } from 'zod'; @@ -35,7 +36,7 @@ export const ZUpdateEmbeddingDocumentRequestSchema = z.object({ email: zEmail(), name: z.string(), role: z.nativeEnum(RecipientRole), - signingOrder: z.number().optional(), + signingOrder: ZRecipientSigningOrderSchema.optional(), // We have an any cast so any changes here you need to update it in the embeding document edit page // Search: "map" to find it fields: ZFieldAndMetaSchema.and( diff --git a/packages/trpc/server/embedding-router/update-embedding-template.types.ts b/packages/trpc/server/embedding-router/update-embedding-template.types.ts index 6fa3571f7..3f7103057 100644 --- a/packages/trpc/server/embedding-router/update-embedding-template.types.ts +++ b/packages/trpc/server/embedding-router/update-embedding-template.types.ts @@ -19,7 +19,7 @@ import { ZFieldWidthSchema, } from '@documenso/lib/types/field'; import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta'; -import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient'; +import { ZRecipientEmailSchema, ZRecipientSigningOrderSchema } from '@documenso/lib/types/recipient'; import { DocumentSigningOrder, RecipientRole } from '@prisma/client'; import { z } from 'zod'; @@ -35,7 +35,7 @@ export const ZUpdateEmbeddingTemplateRequestSchema = z.object({ email: ZRecipientEmailSchema, name: z.string(), role: z.nativeEnum(RecipientRole), - signingOrder: z.number().optional(), + signingOrder: ZRecipientSigningOrderSchema.optional(), // We have an any cast so any changes here you need to update it in the embeding document edit page // Search: "map" to find it fields: ZFieldAndMetaSchema.and( diff --git a/packages/trpc/server/envelope-router/envelope-recipients/create-envelope-recipients.types.ts b/packages/trpc/server/envelope-router/envelope-recipients/create-envelope-recipients.types.ts index e5d20a9cb..816278646 100644 --- a/packages/trpc/server/envelope-router/envelope-recipients/create-envelope-recipients.types.ts +++ b/packages/trpc/server/envelope-router/envelope-recipients/create-envelope-recipients.types.ts @@ -1,5 +1,9 @@ import { ZRecipientAccessAuthTypesSchema, ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-auth'; -import { ZEnvelopeRecipientLiteSchema, ZRecipientEmailSchema } from '@documenso/lib/types/recipient'; +import { + ZEnvelopeRecipientLiteSchema, + ZRecipientEmailSchema, + ZRecipientSigningOrderSchema, +} from '@documenso/lib/types/recipient'; import { RecipientRole } from '@prisma/client'; import { z } from 'zod'; @@ -19,7 +23,7 @@ export const ZCreateEnvelopeRecipientSchema = z.object({ email: ZRecipientEmailSchema, name: z.string().max(255), role: z.nativeEnum(RecipientRole), - signingOrder: z.number().optional(), + signingOrder: ZRecipientSigningOrderSchema.optional(), accessAuth: z.array(ZRecipientAccessAuthTypesSchema).default([]).optional(), actionAuth: z.array(ZRecipientActionAuthTypesSchema).default([]).optional(), }); diff --git a/packages/trpc/server/envelope-router/envelope-recipients/update-envelope-recipients.types.ts b/packages/trpc/server/envelope-router/envelope-recipients/update-envelope-recipients.types.ts index 8381f2396..c36a1abcb 100644 --- a/packages/trpc/server/envelope-router/envelope-recipients/update-envelope-recipients.types.ts +++ b/packages/trpc/server/envelope-router/envelope-recipients/update-envelope-recipients.types.ts @@ -1,5 +1,9 @@ import { ZRecipientAccessAuthTypesSchema, ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-auth'; -import { ZRecipientEmailSchema, ZRecipientLiteSchema } from '@documenso/lib/types/recipient'; +import { + ZRecipientEmailSchema, + ZRecipientLiteSchema, + ZRecipientSigningOrderSchema, +} from '@documenso/lib/types/recipient'; import { RecipientRole } from '@prisma/client'; import { z } from 'zod'; @@ -20,7 +24,7 @@ export const ZUpdateEnvelopeRecipientSchema = z.object({ email: ZRecipientEmailSchema.optional(), name: z.string().max(255).optional(), role: z.nativeEnum(RecipientRole).optional(), - signingOrder: z.number().optional(), + signingOrder: ZRecipientSigningOrderSchema.optional(), accessAuth: z.array(ZRecipientAccessAuthTypesSchema).default([]).optional(), actionAuth: z.array(ZRecipientActionAuthTypesSchema).default([]).optional(), }); diff --git a/packages/trpc/server/envelope-router/set-envelope-recipients.types.ts b/packages/trpc/server/envelope-router/set-envelope-recipients.types.ts index a1cadc346..70c09fdc9 100644 --- a/packages/trpc/server/envelope-router/set-envelope-recipients.types.ts +++ b/packages/trpc/server/envelope-router/set-envelope-recipients.types.ts @@ -1,14 +1,22 @@ import { ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-auth'; -import { ZRecipientEmailSchema, ZRecipientLiteSchema } from '@documenso/lib/types/recipient'; +import { + ZRecipientEmailSchema, + ZRecipientLiteSchema, + ZRecipientSigningOrderSchema, +} from '@documenso/lib/types/recipient'; import { EnvelopeType, RecipientRole } from '@prisma/client'; import { z } from 'zod'; export const ZSetEnvelopeRecipientSchema = z.object({ id: z.number().optional(), + clientId: z + .string() + .optional() + .describe('A temporary ID echoed back on the response so newly created recipients can be reconciled'), email: ZRecipientEmailSchema, name: z.string().max(255), role: z.nativeEnum(RecipientRole), - signingOrder: z.number().optional(), + signingOrder: ZRecipientSigningOrderSchema.optional(), actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]), }); @@ -22,7 +30,11 @@ export const ZSetEnvelopeRecipientsResponseSchema = z.object({ data: ZRecipientLiteSchema.omit({ documentId: true, templateId: true, - }).array(), + }) + .extend({ + clientId: z.string().nullish(), + }) + .array(), }); export type TSetEnvelopeRecipientsRequest = z.infer; diff --git a/packages/trpc/server/envelope-router/sign-envelope-field.ts b/packages/trpc/server/envelope-router/sign-envelope-field.ts index 54f4129a5..b16f329a7 100644 --- a/packages/trpc/server/envelope-router/sign-envelope-field.ts +++ b/packages/trpc/server/envelope-router/sign-envelope-field.ts @@ -4,7 +4,7 @@ import { validateFieldAuth } from '@documenso/lib/server-only/document/validate- import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs'; import { extractFieldInsertionValues } from '@documenso/lib/utils/envelope-signing'; -import { assertRecipientNotExpired } from '@documenso/lib/utils/recipients'; +import { assertRecipientNotExpired, getFieldOwnerWhereInput } from '@documenso/lib/utils/recipients'; import { prisma } from '@documenso/prisma'; import { DocumentStatus, FieldType, RecipientRole, SigningStatus } from '@prisma/client'; import { match } from 'ts-pattern'; @@ -39,20 +39,7 @@ export const signEnvelopeFieldRoute = procedure const field = await prisma.field.findFirst({ where: { id: fieldId, - recipient: - recipient.role === RecipientRole.ASSISTANT - ? { - signingStatus: { - not: SigningStatus.SIGNED, - }, - signingOrder: { - gte: recipient.signingOrder ?? 0, - }, - envelopeId: recipient.envelopeId, - } - : { - id: recipient.id, - }, + recipient: getFieldOwnerWhereInput(recipient), }, include: { envelope: { diff --git a/packages/trpc/server/envelope-router/use-envelope.types.ts b/packages/trpc/server/envelope-router/use-envelope.types.ts index 942e459e7..cd9400286 100644 --- a/packages/trpc/server/envelope-router/use-envelope.types.ts +++ b/packages/trpc/server/envelope-router/use-envelope.types.ts @@ -15,7 +15,7 @@ import { } from '@documenso/lib/types/document-meta'; import { ZEnvelopeAttachmentTypeSchema } from '@documenso/lib/types/envelope-attachment'; import { ZFieldMetaPrefillFieldsSchema } from '@documenso/lib/types/field-meta'; -import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient'; +import { ZRecipientEmailSchema, ZRecipientSigningOrderSchema } from '@documenso/lib/types/recipient'; import { z } from 'zod'; import { zfd } from 'zod-form-data'; @@ -44,7 +44,7 @@ export const ZUseEnvelopePayloadSchema = z.object({ id: z.number().describe('The ID of the recipient in the template.'), email: ZRecipientEmailSchema, name: z.string().max(255).optional(), - signingOrder: z.number().optional(), + signingOrder: ZRecipientSigningOrderSchema.optional(), }), ) .describe('The information of the recipients to create the document with.') diff --git a/packages/trpc/server/recipient-router/schema.ts b/packages/trpc/server/recipient-router/schema.ts index 582688f05..6bb2cb6dc 100644 --- a/packages/trpc/server/recipient-router/schema.ts +++ b/packages/trpc/server/recipient-router/schema.ts @@ -5,7 +5,7 @@ import { ZRecipientActionAuthSchema, ZRecipientActionAuthTypesSchema, } from '@documenso/lib/types/document-auth'; -import { ZRecipientLiteSchema, ZRecipientSchema } from '@documenso/lib/types/recipient'; +import { ZRecipientLiteSchema, ZRecipientSchema, ZRecipientSigningOrderSchema } from '@documenso/lib/types/recipient'; import { zEmail } from '@documenso/lib/utils/zod'; import { RecipientRole } from '@prisma/client'; import { z } from 'zod'; @@ -27,7 +27,7 @@ export const ZCreateRecipientSchema = z.object({ email: zEmail().toLowerCase().min(1).max(254), name: z.string().max(255), role: z.nativeEnum(RecipientRole), - signingOrder: z.number().optional(), + signingOrder: ZRecipientSigningOrderSchema.optional(), accessAuth: z.array(ZRecipientAccessAuthTypesSchema).default([]).optional(), actionAuth: z.array(ZRecipientActionAuthTypesSchema).default([]).optional(), }); @@ -37,7 +37,7 @@ export const ZUpdateRecipientSchema = z.object({ email: zEmail().toLowerCase().min(1).max(254).optional(), name: z.string().max(255).optional(), role: z.nativeEnum(RecipientRole).optional(), - signingOrder: z.number().optional(), + signingOrder: ZRecipientSigningOrderSchema.optional(), accessAuth: z.array(ZRecipientAccessAuthTypesSchema).default([]).optional(), actionAuth: z.array(ZRecipientActionAuthTypesSchema).default([]).optional(), }); @@ -86,7 +86,7 @@ export const ZSetDocumentRecipientsRequestSchema = z.object({ email: zEmail().toLowerCase().min(1).max(254), name: z.string().max(255), role: z.nativeEnum(RecipientRole), - signingOrder: z.number().optional(), + signingOrder: ZRecipientSigningOrderSchema.optional(), actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]), }), ), @@ -148,7 +148,7 @@ export const ZSetTemplateRecipientsRequestSchema = z.object({ ), name: z.string(), role: z.nativeEnum(RecipientRole), - signingOrder: z.number().optional(), + signingOrder: ZRecipientSigningOrderSchema.optional(), actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]), }), ),