This commit is contained in:
David Nguyen
2026-08-26 12:10:29 +10:00
parent 9dc83bdb06
commit 6db71b13d4
70 changed files with 5964 additions and 892 deletions
@@ -11,6 +11,7 @@ import {
import type { TTemplate } from '@documenso/lib/types/template'; import type { TTemplate } from '@documenso/lib/types/template';
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers'; import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields'; import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
import { getNextDictatableRecipient } from '@documenso/lib/utils/recipient-groups';
import type { import type {
TRemovedSignedFieldWithTokenMutationSchema, TRemovedSignedFieldWithTokenMutationSchema,
TSignFieldWithTokenMutationSchema, TSignFieldWithTokenMutationSchema,
@@ -223,27 +224,12 @@ export const DirectTemplateSigningForm = ({
return undefined; return undefined;
} }
const sortedRecipients = template.recipients.sort((a, b) => { const dictatableRecipients = getNextDictatableRecipient({
// Sort by signingOrder first (nulls last), then by id recipients: template.recipients,
if (a.signingOrder === null && b.signingOrder === null) { currentRecipientId: directRecipient.id,
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 === directRecipient.id); return dictatableRecipients ?? undefined;
return currentIndex !== -1 && currentIndex < sortedRecipients.length - 1
? sortedRecipients[currentIndex + 1]
: undefined;
}, [template.templateMeta?.signingOrder, template.recipients, directRecipient.id]); }, [template.templateMeta?.signingOrder, template.recipients, directRecipient.id]);
return ( return (
@@ -100,8 +100,10 @@ export const DocumentSigningCompleteDialog = ({
const { isNameLocked, isEmailLocked } = useEmbedSigningContext() || {}; const { isNameLocked, isEmailLocked } = useEmbedSigningContext() || {};
const canDictateNextSigner = allowDictateNextSigner && Boolean(defaultNextSigner);
const form = useForm<TNextSignerFormSchema>({ const form = useForm<TNextSignerFormSchema>({
resolver: allowDictateNextSigner ? zodResolver(ZNextSignerFormSchema) : undefined, resolver: canDictateNextSigner ? zodResolver(ZNextSignerFormSchema) : undefined,
defaultValues: { defaultValues: {
name: defaultNextSigner?.name ?? '', name: defaultNextSigner?.name ?? '',
email: defaultNextSigner?.email ?? '', email: defaultNextSigner?.email ?? '',
@@ -317,7 +319,7 @@ export const DocumentSigningCompleteDialog = ({
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}> <form onSubmit={form.handleSubmit(onFormSubmit)}>
{allowDictateNextSigner && defaultNextSigner && ( {canDictateNextSigner && (
<div className="mb-4 flex flex-col gap-4"> <div className="mb-4 flex flex-col gap-4">
<div className="flex flex-col gap-4 md:flex-row"> <div className="flex flex-col gap-4 md:flex-row">
<FormField <FormField
@@ -39,7 +39,11 @@ export type DocumentSigningFormProps = {
}) => Promise<void>; }) => Promise<void>;
isSubmitting: boolean; isSubmitting: boolean;
fieldsValidated: () => void; 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<Recipient, 'name' | 'email'>;
}; };
export const DocumentSigningForm = ({ export const DocumentSigningForm = ({
@@ -84,6 +88,13 @@ export const DocumentSigningForm = ({
return fieldsRequiringValidation.filter((field) => field.recipientId === recipient.id); return fieldsRequiringValidation.filter((field) => field.recipientId === recipient.id);
}, [fieldsRequiringValidation, recipient]); }, [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 = () => { const localFieldsValidated = () => {
setValidateUninsertedFields(true); setValidateUninsertedFields(true);
fieldsValidated(); fieldsValidated();
@@ -151,10 +162,8 @@ export const DocumentSigningForm = ({
completeDocument({ nextSigner, accessAuthOptions }) completeDocument({ nextSigner, accessAuthOptions })
} }
recipient={recipient} recipient={recipient}
allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner} allowDictateNextSigner={allowDictateNextSigner}
defaultNextSigner={ defaultNextSigner={defaultNextSigner}
nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined
}
/> />
</div> </div>
</div> </div>
@@ -223,8 +232,8 @@ export const DocumentSigningForm = ({
onClose={() => !isAssistantSubmitting && setIsConfirmationDialogOpen(false)} onClose={() => !isAssistantSubmitting && setIsConfirmationDialogOpen(false)}
onConfirm={handleAssistantConfirmDialogSubmit} onConfirm={handleAssistantConfirmDialogSubmit}
isSubmitting={isAssistantSubmitting} isSubmitting={isAssistantSubmitting}
allowDictateNextSigner={nextRecipient && document.documentMeta?.allowDictateNextSigner} allowDictateNextSigner={allowDictateNextSigner}
defaultNextSigner={nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined} defaultNextSigner={defaultNextSigner}
/> />
</form> </form>
) : ( ) : (
@@ -291,10 +300,8 @@ export const DocumentSigningForm = ({
}) })
} }
recipient={recipient} recipient={recipient}
allowDictateNextSigner={nextRecipient && document.documentMeta?.allowDictateNextSigner} allowDictateNextSigner={allowDictateNextSigner}
defaultNextSigner={ defaultNextSigner={defaultNextSigner}
nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined
}
/> />
</div> </div>
</> </>
@@ -23,7 +23,7 @@ import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible'; import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { Trans } from '@lingui/react/macro'; 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 { FieldType, RecipientRole } from '@prisma/client';
import { LucideChevronDown, LucideChevronUp } from 'lucide-react'; import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
@@ -61,6 +61,12 @@ export type DocumentSigningPageViewV1Props = {
completedFields: CompletedField[]; completedFields: CompletedField[];
isRecipientsTurn: boolean; isRecipientsTurn: boolean;
allRecipients?: RecipientWithFields[]; 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<Recipient, 'name' | 'email'>;
branding: DocumentSigningBranding; branding: DocumentSigningBranding;
includeSenderDetails: boolean; includeSenderDetails: boolean;
}; };
@@ -72,6 +78,7 @@ export const DocumentSigningPageViewV1 = ({
completedFields, completedFields,
isRecipientsTurn, isRecipientsTurn,
allRecipients = [], allRecipients = [],
nextRecipient,
includeSenderDetails, includeSenderDetails,
branding, branding,
}: DocumentSigningPageViewV1Props) => { }: DocumentSigningPageViewV1Props) => {
@@ -142,34 +149,6 @@ export const DocumentSigningPageViewV1 = ({
const selectedSigner = allRecipients?.find((r) => r.id === selectedSignerId); const selectedSigner = allRecipients?.find((r) => r.id === selectedSignerId);
const targetSigner = recipient.role === RecipientRole.ASSISTANT && selectedSigner ? selectedSigner : null; 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 pendingFields = fieldsRequiringValidation.filter((field) => !field.inserted);
const hasPendingFields = pendingFields.length > 0; const hasPendingFields = pendingFields.length > 0;
@@ -6,6 +6,7 @@ import type { EnvelopeForSigningResponse } from '@documenso/lib/server-only/enve
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { isFieldUnsignedAndRequired, isRequiredField } from '@documenso/lib/utils/advanced-fields-helpers'; import { isFieldUnsignedAndRequired, isRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
import { extractFieldInsertionValues } from '@documenso/lib/utils/envelope-signing'; import { extractFieldInsertionValues } from '@documenso/lib/utils/envelope-signing';
import { getNextDictatableRecipient } from '@documenso/lib/utils/recipient-groups';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import type { TSignEnvelopeFieldValue } from '@documenso/trpc/server/envelope-router/sign-envelope-field.types'; 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'; import { EnvelopeType, type Field, FieldType, type Recipient, RecipientRole, SigningStatus } from '@prisma/client';
@@ -290,32 +291,14 @@ export const EnvelopeSigningProvider = ({
.filter((field) => field.inserted); .filter((field) => field.inserted);
const nextRecipient = useMemo(() => { const nextRecipient = useMemo(() => {
if (!envelope.documentMeta.signingOrder || envelope.documentMeta.signingOrder !== 'SEQUENTIAL') { if (envelope.documentMeta.signingOrder !== 'SEQUENTIAL') {
return null; return null;
} }
const sortedRecipients = [...envelope.recipients].sort((a, b) => { return getNextDictatableRecipient({
// Sort by signingOrder first (nulls last), then by id recipients: envelope.recipients,
if (a.signingOrder === null && b.signingOrder === null) { currentRecipientId: recipient.id,
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]
: null;
}, [envelope.documentMeta?.signingOrder, envelope.recipients, recipient.id]); }, [envelope.documentMeta?.signingOrder, envelope.recipients, recipient.id]);
const signField = async ( const signField = async (
@@ -1,42 +1,30 @@
import { useLimits } from '@documenso/ee/server-only/limits/provider/client'; import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value'; import {
import { ZEditorRecipientsFormSchema } from '@documenso/lib/client-only/hooks/use-editor-recipients'; updateEditorSigners,
ZEditorRecipientsFormSchema,
} from '@documenso/lib/client-only/hooks/use-editor-recipients';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider'; import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session'; import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import type { TDetectedRecipientSchema } from '@documenso/lib/server-only/ai/envelope/detect-recipients/schema'; import type { TDetectedRecipientSchema } from '@documenso/lib/server-only/ai/envelope/detect-recipients/schema';
import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth'; import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
import { nanoid } from '@documenso/lib/universal/id'; import { nanoid } from '@documenso/lib/universal/id';
import { import { groupRecipientsBySigningOrder, normalizeGroupedSigningOrders } from '@documenso/lib/utils/recipient-groups';
isAssistantLastSigner, import { canEditorRecipientBeModified } from '@documenso/lib/utils/recipients';
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 { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card';
import { Checkbox } from '@documenso/ui/primitives/checkbox'; import { Checkbox } from '@documenso/ui/primitives/checkbox';
import { SigningOrderConfirmation } from '@documenso/ui/primitives/document-flow/signing-order-confirmation'; 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 { 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 { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast'; 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 { plural } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { DocumentSigningOrder, EnvelopeType, RecipientRole, SendStatus } from '@prisma/client'; import { DocumentSigningOrder, RecipientRole, SendStatus } from '@prisma/client';
import { motion } from 'framer-motion'; import { HelpCircleIcon, PlusIcon, SparklesIcon } from 'lucide-react';
import { GripVerticalIcon, HelpCircleIcon, PlusIcon, SparklesIcon, TrashIcon } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useFieldArray, useWatch } from 'react-hook-form'; import { useFieldArray, useWatch } from 'react-hook-form';
import { useRevalidator, useSearchParams } from 'react-router'; 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 { AiRecipientDetectionDialog } from '~/components/dialogs/ai-recipient-detection-dialog';
import { useCurrentTeam } from '~/providers/team'; import { useCurrentTeam } from '~/providers/team';
import { RecipientStepList } from './recipient-step-list';
export const EnvelopeEditorRecipientForm = () => { export const EnvelopeEditorRecipientForm = () => {
const { envelope, setRecipientsDebounced, updateEnvelope, editorRecipients, isEmbedded, editorConfig } = const { envelope, setRecipientsDebounced, updateEnvelope, editorRecipients, isEmbedded, editorConfig } =
useCurrentEnvelopeEditor(); useCurrentEnvelopeEditor();
@@ -53,7 +43,6 @@ export const EnvelopeEditorRecipientForm = () => {
const organisation = useCurrentOrganisation(); const organisation = useCurrentOrganisation();
const team = useCurrentTeam(); const team = useCurrentTeam();
const { t } = useLingui();
const { toast } = useToast(); const { toast } = useToast();
const { remaining } = useLimits(); const { remaining } = useLimits();
const { sessionData } = useOptionalSession(); const { sessionData } = useOptionalSession();
@@ -61,7 +50,6 @@ export const EnvelopeEditorRecipientForm = () => {
const user = sessionData?.user; const user = sessionData?.user;
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const [recipientSearchQuery, setRecipientSearchQuery] = useState('');
const [isAiEnableDialogOpen, setIsAiEnableDialogOpen] = useState(false); const [isAiEnableDialogOpen, setIsAiEnableDialogOpen] = useState(false);
// AI recipient detection dialog state // AI recipient detection dialog state
@@ -107,23 +95,8 @@ export const EnvelopeEditorRecipientForm = () => {
}); });
}; };
const debouncedRecipientSearchQuery = useDebouncedValue(recipientSearchQuery, 500);
const $sensorApi = useRef<SensorAPI | null>(null);
const isFirstRender = useRef(true); const isFirstRender = useRef(true);
const { recipients, fields } = envelope; const { recipients } = envelope;
const { data: recipientSuggestionsData, isLoading } = trpc.recipient.suggestions.find.useQuery(
{
query: debouncedRecipientSearchQuery,
},
{
enabled: debouncedRecipientSearchQuery.length > 1 && !isEmbedded,
retry: false,
},
);
const recipientSuggestions = recipientSuggestionsData?.results || [];
const { form } = editorRecipients; const { form } = editorRecipients;
@@ -161,17 +134,20 @@ export const EnvelopeEditorRecipientForm = () => {
}, [watchedSigners]); }, [watchedSigners]);
const normalizeSigningOrders = (signers: typeof 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; // Keep a mounted field array for `signers` so react-hook-form reconciles
// whole-array `setValue` calls atomically. Without it, reordering the array
const { fields: signers, remove: removeSigner } = useFieldArray({ // leaves stale partial entries in watched values (missing email/name/role),
// which breaks validation and the autosave sync.
useFieldArray({
control, control,
name: 'signers', name: 'signers',
keyName: 'nativeId',
}); });
const stepCount = useMemo(() => groupRecipientsBySigningOrder(watchedSigners).steps.length, [watchedSigners]);
const emptySignerIndex = watchedSigners.findIndex( const emptySignerIndex = watchedSigners.findIndex(
(signer) => (signer) =>
!signer.name && !signer.email && envelope.fields.filter((field) => field.recipientId === signer.id).length === 0, !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); 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( const isUserAlreadyARecipient = watchedSigners.some(
(signer) => signer.email.toLowerCase() === currentEditorEmail?.toLowerCase(), (signer) => Boolean(currentEditorEmail) && signer.email?.toLowerCase() === currentEditorEmail?.toLowerCase(),
); );
const hasDocumentBeenSent = recipients.some( const hasDocumentBeenSent = recipients.some(
(recipient) => recipient.role !== RecipientRole.CC && recipient.sendStatus === SendStatus.SENT, (recipient) => recipient.role !== RecipientRole.CC && recipient.sendStatus === SendStatus.SENT,
); );
const canRecipientBeModified = (recipientId?: number) => { const canRecipientBeModified = (recipientId?: number) => canEditorRecipientBeModified(envelope, recipientId);
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 appendNormalizedSigner = (signer: (typeof watchedSigners)[number], shouldFocus = false) => { const appendNormalizedSigner = (signer: (typeof watchedSigners)[number], shouldFocus = false) => {
const updatedSigners = normalizeSigningOrders([...form.getValues('signers'), signer]); const updatedSigners = normalizeSigningOrders([...form.getValues('signers'), signer]);
form.setValue('signers', updatedSigners, { updateEditorSigners(form, updatedSigners);
shouldValidate: true,
shouldDirty: true,
});
if (shouldFocus) { if (shouldFocus) {
const signerIndex = updatedSigners.findIndex((updatedSigner) => updatedSigner.formId === signer.formId); const signerIndex = updatedSigners.findIndex((updatedSigner) => updatedSigner.formId === signer.formId);
@@ -233,7 +192,7 @@ export const EnvelopeEditorRecipientForm = () => {
email: '', email: '',
role: RecipientRole.SIGNER, role: RecipientRole.SIGNER,
actionAuth: [], 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 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) { if (currentSigners.length === 1 && !currentSigners[0].name && !currentSigners[0].email) {
form.setValue( updateEditorSigners(
'signers', form,
detectedRecipients.map((recipient, index) => ({ detectedRecipients.map((recipient, index) => ({
formId: nanoid(12), formId: nanoid(12),
name: recipient.name, name: recipient.name,
@@ -255,10 +214,6 @@ export const EnvelopeEditorRecipientForm = () => {
actionAuth: [], actionAuth: [],
signingOrder: index + 1, signingOrder: index + 1,
})), })),
{
shouldValidate: true,
shouldDirty: true,
},
); );
return; return;
@@ -285,10 +240,7 @@ export const EnvelopeEditorRecipientForm = () => {
nextSigningOrder += 1; nextSigningOrder += 1;
} }
form.setValue('signers', normalizeSigningOrders(currentSigners), { updateEditorSigners(form, normalizeSigningOrders(currentSigners));
shouldValidate: true,
shouldDirty: true,
});
toast({ toast({
title: plural(detectedRecipients.length, { 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 = () => { const onAddSelfSigner = () => {
if (emptySignerIndex !== -1) { if (emptySignerIndex !== -1) {
setValue(`signers.${emptySignerIndex}.name`, currentEditorName ?? '', { setValue(`signers.${emptySignerIndex}.name`, currentEditorName ?? '', {
@@ -348,7 +274,7 @@ export const EnvelopeEditorRecipientForm = () => {
email: currentEditorEmail ?? '', email: currentEditorEmail ?? '',
role: RecipientRole.SIGNER, role: RecipientRole.SIGNER,
actionAuth: [], actionAuth: [],
signingOrder: activeRecipientCount + 1, signingOrder: stepCount + 1,
}, },
true, 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(() => { const handleSigningOrderDisable = useCallback(() => {
setShowSigningOrderConfirmation(false); setShowSigningOrderConfirmation(false);
@@ -504,10 +294,8 @@ export const EnvelopeEditorRecipientForm = () => {
})), })),
); );
form.setValue('signers', updatedSigners, { updateEditorSigners(form, updatedSigners);
shouldValidate: true,
shouldDirty: true,
});
form.setValue('signingOrder', DocumentSigningOrder.PARALLEL, { form.setValue('signingOrder', DocumentSigningOrder.PARALLEL, {
shouldValidate: true, shouldValidate: true,
shouldDirty: true, shouldDirty: true,
@@ -588,7 +376,7 @@ export const EnvelopeEditorRecipientForm = () => {
}, [formValues]); }, [formValues]);
const recipientCountLimit = organisation.organisationClaim.recipientCount; const recipientCountLimit = organisation.organisationClaim.recipientCount;
const isOverRecipientLimit = recipientCountLimit > 0 && signers.length > recipientCountLimit; const isOverRecipientLimit = recipientCountLimit > 0 && watchedSigners.length > recipientCountLimit;
return ( return (
<Card backdropBlur={false} className="border"> <Card backdropBlur={false} className="border">
@@ -644,7 +432,7 @@ export const EnvelopeEditorRecipientForm = () => {
type="button" type="button"
className="flex-1" className="flex-1"
size="sm" size="sm"
disabled={isSubmitting || signers.length >= remaining.recipients} disabled={isSubmitting || watchedSigners.length >= remaining.recipients}
onClick={() => onAddSigner()} onClick={() => onAddSigner()}
> >
<PlusIcon className="mr-1 -ml-1 h-5 w-5" /> <PlusIcon className="mr-1 -ml-1 h-5 w-5" />
@@ -794,287 +582,7 @@ export const EnvelopeEditorRecipientForm = () => {
)} )}
</div> </div>
<DragDropContext <RecipientStepList showAdvancedSettings={showAdvancedSettings} />
onDragEnd={onDragEnd}
sensors={[
(api: SensorAPI) => {
$sensorApi.current = api;
},
]}
>
<Droppable droppableId="signers">
{(provided) => (
<div {...provided.droppableProps} ref={provided.innerRef} className="flex w-full flex-col gap-y-2">
{signers.map((signer, index) => {
const isDirectRecipient =
envelope.type === EnvelopeType.TEMPLATE &&
envelope.directLink !== null &&
signer.id === envelope.directLink.directTemplateRecipientId;
return (
<Draggable
key={`${signer.nativeId}-${signer.signingOrder}`}
draggableId={signer['nativeId']}
index={index}
isDragDisabled={
!isSigningOrderSequential ||
isSubmitting ||
isCcRecipient(signer) ||
!canRecipientBeModified(signer.id) ||
!signer.signingOrder
}
>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
className={cn('py-1', {
'pointer-events-none rounded-md bg-widget-foreground pt-2': snapshot.isDragging,
})}
>
<motion.fieldset
data-native-id={signer.id}
disabled={isSubmitting || !canRecipientBeModified(signer.id)}
className={cn('pb-2', {
'border-b pb-4': showAdvancedSettings && index !== signers.length - 1,
'pt-2': showAdvancedSettings && index === 0,
'pr-3': isSigningOrderSequential,
})}
>
<div className="flex flex-row items-center gap-x-2">
{isSigningOrderSequential && isCcRecipient(signer) && (
<div className="mt-auto h-10 w-[4.25rem] flex-shrink-0" />
)}
{isSigningOrderSequential && !isCcRecipient(signer) && (
<FormField
control={form.control}
name={`signers.${index}.signingOrder`}
render={({ field }) => (
<FormItem
className={cn('mt-auto flex items-center gap-x-1 space-y-0', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.signingOrder,
})}
>
<GripVerticalIcon className="h-5 w-5 flex-shrink-0 opacity-40" />
<FormControl>
<Input
type="number"
max={activeRecipientCount}
data-testid="signing-order-input"
className={cn(
'w-10 text-center',
'[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none',
)}
{...field}
onChange={(e) => {
field.onChange(e);
handleSigningOrderChange(index, e.target.value);
}}
onBlur={(e) => {
field.onBlur();
handleSigningOrderChange(index, e.target.value);
}}
disabled={
snapshot.isDragging || isSubmitting || !canRecipientBeModified(signer.id)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name={`signers.${index}.email`}
render={({ field }) => (
<FormItem
className={cn('relative w-full', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.email,
})}
>
{!showAdvancedSettings && index === 0 && (
<FormLabel>
<Trans>Email</Trans>
</FormLabel>
)}
<FormControl>
<RecipientAutoCompleteInput
type="email"
placeholder={t`Email`}
value={field.value}
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id) ||
isDirectRecipient
}
options={recipientSuggestions}
onSelect={(suggestion) =>
handleRecipientAutoCompleteSelect(index, suggestion)
}
onSearchQueryChange={(query) => {
field.onChange(query);
setRecipientSearchQuery(query);
}}
loading={isLoading}
data-testid="signer-email-input"
maxLength={254}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`signers.${index}.name`}
render={({ field }) => (
<FormItem
className={cn('w-full', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.name,
})}
>
{!showAdvancedSettings && index === 0 && (
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
)}
<FormControl>
<RecipientAutoCompleteInput
type="text"
placeholder={t`Recipient ${index + 1}`}
{...field}
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id) ||
isDirectRecipient
}
options={recipientSuggestions}
onSelect={(suggestion) =>
handleRecipientAutoCompleteSelect(index, suggestion)
}
onSearchQueryChange={(query) => {
field.onChange(query);
setRecipientSearchQuery(query);
}}
loading={isLoading}
maxLength={255}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`signers.${index}.role`}
render={({ field }) => (
<FormItem
className={cn('mt-auto w-fit', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.role,
})}
>
<FormControl>
<RecipientRoleSelect
{...field}
hideAssistantRole={!editorConfig.recipients?.allowAssistantRole}
hideCCerRole={!editorConfig.recipients?.allowCCerRole}
hideViewerRole={!editorConfig.recipients?.allowViewerRole}
hideApproverRole={!editorConfig.recipients?.allowApproverRole}
isAssistantEnabled={isSigningOrderSequential}
onValueChange={(value) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
handleRoleChange(index, value as RecipientRole);
}}
disabled={
snapshot.isDragging || isSubmitting || !canRecipientBeModified(signer.id)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
variant="ghost"
className={cn('mt-auto px-2', {
'mb-6': form.formState.errors.signers?.[index],
})}
data-testid="remove-signer-button"
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id) ||
signers.length === 1 ||
isDirectRecipient
}
onClick={() => onRemoveSigner(index)}
>
<TrashIcon className="h-4 w-4" />
</Button>
</div>
{showAdvancedSettings && organisation.organisationClaim.flags.cfr21 && (
<FormField
control={form.control}
name={`signers.${index}.actionAuth`}
render={({ field }) => (
<FormItem
className={cn('mt-2 w-full', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.actionAuth,
'pl-6': isSigningOrderSequential,
})}
>
<FormControl>
<RecipientActionAuthSelect
{...field}
onValueChange={field.onChange}
disabled={
snapshot.isDragging || isSubmitting || !canRecipientBeModified(signer.id)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</motion.fieldset>
</div>
)}
</Draggable>
);
})}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
<FormErrorMessage <FormErrorMessage
className="mt-2" className="mt-2"
@@ -0,0 +1,233 @@
import type { TEditorRecipientsFormSchema } 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 { isCcRecipient } from '@documenso/lib/utils/recipients';
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 { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { FormControl, FormField, FormItem, FormMessage } from '@documenso/ui/primitives/form/form';
import type { DraggableProvidedDragHandleProps } from '@hello-pangea/dnd';
import { useLingui } from '@lingui/react/macro';
import { EnvelopeType, type RecipientRole } from '@prisma/client';
import { GripVerticalIcon, TrashIcon } from 'lucide-react';
import { memo } from 'react';
import { useFormContext } from 'react-hook-form';
type TEditorSigner = TEditorRecipientsFormSchema['signers'][number];
export type RecipientRowProps = {
signerIndex: number;
signer: TEditorSigner;
isSequential: boolean;
isInputDisabled: boolean;
canBeModified: boolean;
isRemoveDisabled: boolean;
showAdvancedSettings: boolean;
dragHandleProps?: DraggableProvidedDragHandleProps | null;
recipientSuggestions: RecipientAutoCompleteOption[];
isLoadingSuggestions: boolean;
onRoleChange: (signerIndex: number, role: RecipientRole) => 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<TEditorRecipientsFormSchema>();
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 (
<fieldset data-native-id={signer.id} disabled={isSubmitting || !canBeModified} className="py-1">
<div className="flex flex-row items-center gap-x-2">
{isSequential && !isCcRecipient(signer) && (
<span
{...(dragHandleProps ?? {})}
data-testid="recipient-row-drag-handle"
className={cn(
'mt-auto -ml-1.5 flex h-10 w-8 flex-shrink-0 cursor-grab items-center justify-center rounded-md hover:bg-foreground/5 active:cursor-grabbing',
{
'mb-6': rowErrors,
'cursor-default hover:bg-transparent': !dragHandleProps,
},
)}
>
<GripVerticalIcon
className={cn('h-5 w-5 flex-shrink-0 opacity-40', {
'opacity-10': !dragHandleProps,
})}
/>
</span>
)}
<FormField
control={form.control}
name={`signers.${signerIndex}.email`}
render={({ field }) => (
<FormItem
className={cn('relative w-full', {
'mb-6': rowErrors && !rowErrors.email,
})}
>
<FormControl>
<RecipientAutoCompleteInput
type="email"
placeholder={t`Email`}
value={field.value}
disabled={isFieldDisabled || isDirectRecipient}
options={recipientSuggestions}
onSelect={(suggestion) => onAutoCompleteSelect(signerIndex, suggestion)}
onSearchQueryChange={(query) => {
field.onChange(query);
onSearchQueryChange(query);
}}
loading={isLoadingSuggestions}
data-testid="signer-email-input"
maxLength={254}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`signers.${signerIndex}.name`}
render={({ field }) => (
<FormItem
className={cn('w-full', {
'mb-6': rowErrors && !rowErrors.name,
})}
>
<FormControl>
<RecipientAutoCompleteInput
type="text"
placeholder={t`Recipient ${signerIndex + 1}`}
{...field}
disabled={isFieldDisabled || isDirectRecipient}
options={recipientSuggestions}
onSelect={(suggestion) => onAutoCompleteSelect(signerIndex, suggestion)}
onSearchQueryChange={(query) => {
field.onChange(query);
onSearchQueryChange(query);
}}
loading={isLoadingSuggestions}
maxLength={255}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`signers.${signerIndex}.role`}
render={({ field }) => (
<FormItem
className={cn('mt-auto w-fit', {
'mb-6': rowErrors && !rowErrors.role,
})}
>
<FormControl>
<RecipientRoleSelect
{...field}
hideAssistantRole={!editorConfig.recipients?.allowAssistantRole}
hideCCerRole={!editorConfig.recipients?.allowCCerRole}
hideViewerRole={!editorConfig.recipients?.allowViewerRole}
hideApproverRole={!editorConfig.recipients?.allowApproverRole}
isAssistantEnabled={isSequential}
onValueChange={(value) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
onRoleChange(signerIndex, value as RecipientRole);
}}
disabled={isFieldDisabled}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
variant="ghost"
className={cn('mt-auto px-2', {
'mb-6': rowErrors,
})}
data-testid="remove-signer-button"
disabled={isFieldDisabled || isRemoveDisabled || isDirectRecipient}
onClick={() => onRemove(signerIndex)}
>
<TrashIcon className="h-4 w-4" />
</Button>
</div>
{showAdvancedSettings && organisation.organisationClaim.flags.cfr21 && (
<FormField
control={form.control}
name={`signers.${signerIndex}.actionAuth`}
render={({ field }) => (
<FormItem
className={cn('mt-2 w-full', {
'mb-6': rowErrors && !rowErrors.actionAuth,
'pl-6': isSequential,
})}
>
<FormControl>
<RecipientActionAuthSelect {...field} onValueChange={field.onChange} disabled={isFieldDisabled} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</fieldset>
);
};
/**
* 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);
@@ -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<TEditorSigner>;
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<string, number>;
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 }) => (
<Droppable droppableId={droppableId} type="RECIPIENT">
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.droppableProps}
data-testid="recipient-step-gap"
className={cn('flex h-6 items-center', {
'gap-active': snapshot.isDraggingOver,
})}
>
<div
className={cn('h-[3px] w-full rounded-full bg-primary opacity-0 transition-opacity duration-100', {
'opacity-100': snapshot.isDraggingOver,
})}
/>
{provided.placeholder}
</div>
)}
</Droppable>
);
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 (
<div
ref={draggableProvided.innerRef}
{...draggableProvided.draggableProps}
style={getDraggableStyle(draggableProvided, draggableSnapshot)}
className={cn({
'pointer-events-none': draggableSnapshot.isDragging,
})}
>
<RecipientStepGap droppableId={`gap-${stepAnchor}`} />
<Droppable droppableId={`step-members-${stepAnchor}`} type="RECIPIENT" isDropDisabled={!isGroupingEnabled}>
{(droppableProvided, droppableSnapshot) => {
const isJoinTarget = draggingType === 'RECIPIENT' && droppableSnapshot.isDraggingOver;
const isHighlighted = isCombineTarget || isJoinTarget;
return (
<div
ref={droppableProvided.innerRef}
{...droppableProvided.droppableProps}
data-testid="recipient-step-card"
className={cn('relative rounded-lg border bg-background px-3 pt-2 pb-1 transition-shadow', {
'border-primary/60 bg-primary/5': isGroup,
'bg-widget-foreground shadow-lg': draggableSnapshot.isDragging,
'border-primary ring-1 ring-primary': isHighlighted,
})}
>
{isHighlighted && (
<Badge
variant="default"
size="small"
className="absolute -top-3 right-4 z-10 flex items-center gap-x-1 shadow-sm"
>
<Users2Icon className="h-3 w-3" />
<Trans>Release to group</Trans>
</Badge>
)}
<div className="flex flex-row items-center gap-x-1">
<span
{...(draggableProvided.dragHandleProps ?? {})}
data-testid="step-drag-handle"
className={cn(
'-my-1 -ml-1.5 flex h-8 w-8 flex-shrink-0 cursor-grab items-center justify-center rounded-md hover:bg-foreground/5 active:cursor-grabbing',
{ 'pointer-events-none opacity-30': isStepLocked },
)}
>
<GripVerticalIcon className="h-4 w-4 opacity-60" />
</span>
<Badge variant={isGroup ? 'default' : 'neutral'} size="small">
<Trans>Group {step.order}</Trans>
</Badge>
{isGroup && (
<>
<span className="ml-1 flex items-center gap-x-1.5 text-green-700 text-xs dark:text-green-400">
<Users2Icon className="h-3.5 w-3.5" />
<Trans>{step.members.length} recipients · any order</Trans>
</span>
<Button
type="button"
variant="link"
size="sm"
data-testid="ungroup-step-button"
className="ml-auto h-auto p-0 text-xs"
disabled={isStepLocked || isSubmitting}
onClick={() => onUngroup(stepIndex)}
>
<Trans>Ungroup</Trans>
</Button>
</>
)}
</div>
{step.members.map((member, memberIndex) => {
const signerIndex = flatIndexByFormId.get(member.formId) ?? -1;
const canBeModified = canSignerBeModified(member);
return (
<Draggable
key={member.formId}
draggableId={`recipient-${member.formId}`}
index={memberIndex}
isDragDisabled={isSubmitting || isStepLocked}
>
{(memberProvided, memberSnapshot) => (
<div
ref={memberProvided.innerRef}
{...memberProvided.draggableProps}
style={getDraggableStyle(memberProvided, memberSnapshot)}
className={cn({
'rounded-md bg-widget-foreground shadow-lg': memberSnapshot.isDragging,
})}
>
<RecipientRow
signerIndex={signerIndex}
signer={member}
isSequential={true}
isInputDisabled={memberSnapshot.isDragging || draggableSnapshot.isDragging}
canBeModified={canBeModified}
isRemoveDisabled={isRemoveDisabled}
dragHandleProps={memberProvided.dragHandleProps}
{...rowProps}
/>
</div>
)}
</Draggable>
);
})}
{droppableProvided.placeholder}
</div>
);
}}
</Droppable>
{isLastStep && <RecipientStepGap droppableId="gap-end" />}
</div>
);
};
@@ -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<DraggingType>(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 (
<div>
{!showAdvancedSettings && !isSequential && (
<div className="mb-1 flex flex-row gap-x-2 text-sm">
<span className="w-full">
<Trans>Email</Trans>
</span>
<span className="w-full">
<Trans>Name</Trans>
</span>
<span className="w-[7.5rem] flex-shrink-0" />
</div>
)}
{!isSequential ? (
<div className="flex w-full flex-col">
{watchedSigners.map((signer, index) => (
<RecipientRow
key={signer.formId}
signerIndex={index}
signer={signer}
isSequential={false}
isInputDisabled={false}
canBeModified={canSignerBeModified(signer)}
isRemoveDisabled={isRemoveDisabled}
dragHandleProps={null}
{...sharedRowProps}
/>
))}
</div>
) : (
<>
<DragDropContext onBeforeCapture={onBeforeCapture} onDragEnd={onDragEnd}>
<Droppable droppableId="recipient-steps" type="STEP" isCombineEnabled={isGroupingEnabled}>
{(provided) => (
<div {...provided.droppableProps} ref={provided.innerRef} className="flex w-full flex-col">
{steps.map((step, stepIndex) => {
const isStepLocked = stepIndex <= lastLockedStepIndex;
return (
<Draggable
key={`step-${step.members[0].formId}`}
draggableId={`step-${step.members[0].formId}`}
index={stepIndex}
isDragDisabled={isSubmitting || isStepLocked}
>
{(draggableProvided, draggableSnapshot) => (
<RecipientStepCard
stepIndex={stepIndex}
step={step}
isLastStep={stepIndex === steps.length - 1}
draggableProvided={draggableProvided}
draggableSnapshot={draggableSnapshot}
draggingType={draggingType}
isGroupingEnabled={isGroupingEnabled}
isStepLocked={isStepLocked}
isRemoveDisabled={isRemoveDisabled}
flatIndexByFormId={flatIndexByFormId}
canSignerBeModified={canSignerBeModified}
isSubmitting={isSubmitting}
onUngroup={handleUngroup}
rowProps={sharedRowProps}
/>
)}
</Draggable>
);
})}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
{ccRecipients.length > 0 && (
<div className="my-1 rounded-lg border px-3 py-1.5">
<Badge variant="neutral" size="small">
<Trans>Receives Copy</Trans>
</Badge>
{ccRecipients.map((signer) => (
<div key={signer.formId} className="my-1">
<RecipientRow
signerIndex={flatIndexByFormId.get(signer.formId) ?? -1}
signer={signer}
isSequential={true}
isInputDisabled={false}
canBeModified={canSignerBeModified(signer)}
isRemoveDisabled={isRemoveDisabled}
dragHandleProps={null}
{...sharedRowProps}
/>
</div>
))}
</div>
)}
</>
)}
</div>
);
};
@@ -43,6 +43,10 @@ export async function loader({ params, request }: Route.LoaderArgs) {
throw new Response('Not Found', { status: 404 }); throw new Response('Not Found', { status: 404 });
} }
if (document.internalVersion !== 1) {
throw redirect(`${documentRootPath}/${document.envelopeId}/edit`);
}
const documentVisibility = document.visibility; const documentVisibility = document.visibility;
const currentTeamMemberRole = team.currentTeamRole; const currentTeamMemberRole = team.currentTeamRole;
const isRecipient = document.recipients.find((recipient) => recipient.email === user.email); const isRecipient = document.recipients.find((recipient) => recipient.email === user.email);
@@ -42,6 +42,10 @@ export async function loader({ params, request }: Route.LoaderArgs) {
throw redirect(templateRootPath); throw redirect(templateRootPath);
} }
if (template.internalVersion !== 1) {
throw redirect(`${templateRootPath}/${template.envelopeId}/edit`);
}
return superLoaderJson({ return superLoaderJson({
template: { template: {
...template, ...template,
@@ -93,22 +93,23 @@ const handleV1Loader = async ({ params, request }: Route.LoaderArgs) => {
}) })
: [recipient]; : [recipient];
if ( // Dictation eligibility must be decided here, over the FULL recipient list
document.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL && // — the same computation the completion route enforces. `allRecipients` is
recipient.role !== RecipientRole.ASSISTANT // role-scoped (assistants only see strictly later steps, not their own
) { // group peers), so deriving it client-side from that list would offer
const nextPendingRecipient = await getNextPendingRecipient({ // dictation the server then silently ignores.
documentId: document.id, const nextPendingRecipient =
currentRecipientId: recipient.id, document.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL
}); ? await getNextPendingRecipient({
documentId: document.id,
currentRecipientId: recipient.id,
})
: null;
if (nextPendingRecipient) { // Only the identity is needed client-side (dictation flag + prefill).
allRecipients.push({ const nextRecipient = nextPendingRecipient
...nextPendingRecipient, ? { name: nextPendingRecipient.name, email: nextPendingRecipient.email }
fields: [], : null;
});
}
}
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({ const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
documentAuth: document.authOptions, documentAuth: document.authOptions,
@@ -170,6 +171,7 @@ const handleV1Loader = async ({ params, request }: Route.LoaderArgs) => {
recipient, recipient,
recipientWithFields, recipientWithFields,
allRecipients, allRecipients,
nextRecipient,
completedFields, completedFields,
recipientSignature, recipientSignature,
isRecipientsTurn, isRecipientsTurn,
@@ -414,6 +416,7 @@ const SigningPageV1 = ({ data }: { data: Awaited<ReturnType<typeof handleV1Loade
recipientSignature, recipientSignature,
isRecipientsTurn, isRecipientsTurn,
allRecipients, allRecipients,
nextRecipient,
includeSenderDetails, includeSenderDetails,
branding, branding,
recipientWithFields, recipientWithFields,
@@ -486,6 +489,7 @@ const SigningPageV1 = ({ data }: { data: Awaited<ReturnType<typeof handleV1Loade
completedFields={completedFields} completedFields={completedFields}
isRecipientsTurn={isRecipientsTurn} isRecipientsTurn={isRecipientsTurn}
allRecipients={allRecipients} allRecipients={allRecipients}
nextRecipient={nextRecipient ?? undefined}
includeSenderDetails={includeSenderDetails} includeSenderDetails={includeSenderDetails}
branding={branding} branding={branding}
/> />
+5 -4
View File
@@ -11,6 +11,7 @@ import {
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email'; import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import { ZEnvelopeAttachmentTypeSchema } from '@documenso/lib/types/envelope-attachment'; import { ZEnvelopeAttachmentTypeSchema } from '@documenso/lib/types/envelope-attachment';
import { ZFieldMetaPrefillFieldsSchema, ZFieldMetaSchema } from '@documenso/lib/types/field-meta'; import { ZFieldMetaPrefillFieldsSchema, ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
import { ZRecipientSigningOrderSchema } from '@documenso/lib/types/recipient';
import { zEmail } from '@documenso/lib/utils/zod'; import { zEmail } from '@documenso/lib/utils/zod';
import { import {
DocumentDataType, DocumentDataType,
@@ -145,7 +146,7 @@ export const ZCreateDocumentMutationSchema = z.object({
name: z.string().min(1), name: z.string().min(1),
email: zEmail().min(1), email: zEmail().min(1),
role: z.nativeEnum(RecipientRole).optional().default(RecipientRole.SIGNER), role: z.nativeEnum(RecipientRole).optional().default(RecipientRole.SIGNER),
signingOrder: z.number().nullish(), signingOrder: ZRecipientSigningOrderSchema.nullish(),
}), }),
), ),
meta: z meta: z
@@ -235,7 +236,7 @@ export const ZCreateDocumentFromTemplateMutationSchema = z.object({
name: z.string().min(1), name: z.string().min(1),
email: zEmail().min(1), email: zEmail().min(1),
role: z.nativeEnum(RecipientRole).optional().default(RecipientRole.SIGNER), role: z.nativeEnum(RecipientRole).optional().default(RecipientRole.SIGNER),
signingOrder: z.number().nullish(), signingOrder: ZRecipientSigningOrderSchema.nullish(),
}), }),
), ),
meta: z meta: z
@@ -315,7 +316,7 @@ export const ZGenerateDocumentFromTemplateMutationSchema = z.object({
id: z.number(), id: z.number(),
email: zEmail(), email: zEmail(),
name: z.string().optional(), name: z.string().optional(),
signingOrder: z.number().optional(), signingOrder: ZRecipientSigningOrderSchema.optional(),
}), }),
) )
.refine( .refine(
@@ -389,7 +390,7 @@ export const ZCreateRecipientMutationSchema = z.object({
name: z.string().min(1), name: z.string().min(1),
email: zEmail().min(1), email: zEmail().min(1),
role: z.nativeEnum(RecipientRole).optional().default(RecipientRole.SIGNER), role: z.nativeEnum(RecipientRole).optional().default(RecipientRole.SIGNER),
signingOrder: z.number().nullish(), signingOrder: ZRecipientSigningOrderSchema.nullish(),
authOptions: z authOptions: z
.object({ .object({
actionAuth: z actionAuth: z
@@ -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');
});
@@ -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);
});
@@ -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]);
});
@@ -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');
});
@@ -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);
});
@@ -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<SeededGroupEnvelope> => {
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<string, unknown>) => {
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');
});
});
@@ -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);
});
@@ -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);
});
@@ -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);
});
@@ -11,7 +11,7 @@ import {
assertRecipientRole, assertRecipientRole,
getRecipientEmailInputs, getRecipientEmailInputs,
getRecipientRows, getRecipientRows,
getSigningOrderInputs, getRecipientStepCards,
openDocumentEnvelopeEditor, openDocumentEnvelopeEditor,
setRecipientEmail, setRecipientEmail,
setRecipientName, setRecipientName,
@@ -34,14 +34,14 @@ const assertCcDisplayedLastWithNoOrderInput = async (root: Page) => {
await assertRecipientRole(root, 1, 'Needs to sign'); await assertRecipientRole(root, 1, 'Needs to sign');
await assertRecipientRole(root, 2, 'Receives copy'); await assertRecipientRole(root, 2, 'Receives copy');
// Only the two signers have signing order inputs, showing 1 and 2. // Only the two signers render as ordered group cards, showing groups 1 and 2.
await expect(getSigningOrderInputs(root)).toHaveCount(2); await expect(getRecipientStepCards(root)).toHaveCount(2);
await expect(getSigningOrderInputs(root).nth(0)).toHaveValue('1'); await expect(root.getByText('Group 1', { exact: true })).toBeVisible();
await expect(getSigningOrderInputs(root).nth(1)).toHaveValue('2'); 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); 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', () => { test.describe('document editor', () => {
@@ -61,8 +61,8 @@ test.describe('document editor', () => {
await setRecipientName(root, 1, CC_RECIPIENT.name); await setRecipientName(root, 1, CC_RECIPIENT.name);
await setRecipientRole(root, 1, 'Receives copy'); await setRecipientRole(root, 1, 'Receives copy');
// Once the row becomes CC, its signing order input disappears. // Once the row becomes CC, it drops out of the ordered group cards.
await expect(getSigningOrderInputs(root)).toHaveCount(1); await expect(getRecipientStepCards(root)).toHaveCount(1);
// Add signer B third. The new row is inserted before the CC recipient, // Add signer B third. The new row is inserted before the CC recipient,
// which is kept last by the client-side sorting. // which is kept last by the client-side sorting.
@@ -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],
]);
});
});
@@ -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);
});
});
@@ -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);
});
});
@@ -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/);
});
@@ -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);
});
@@ -9,11 +9,12 @@ import {
clickAddMyselfButton, clickAddMyselfButton,
clickAddSignerButton, clickAddSignerButton,
clickEnvelopeEditorStep, clickEnvelopeEditorStep,
dragRecipientRowToGap,
getEnvelopeEditorSettingsTrigger, getEnvelopeEditorSettingsTrigger,
getRecipientEmailInputs, getRecipientEmailInputs,
getRecipientNameInputs, getRecipientNameInputs,
getRecipientRemoveButtons, getRecipientRemoveButtons,
getSigningOrderInputs, getRecipientStepCards,
openDocumentEnvelopeEditor, openDocumentEnvelopeEditor,
openEmbeddedEnvelopeEditor, openEmbeddedEnvelopeEditor,
openTemplateEnvelopeEditor, openTemplateEnvelopeEditor,
@@ -21,7 +22,6 @@ import {
setRecipientEmail, setRecipientEmail,
setRecipientName, setRecipientName,
setRecipientRole, setRecipientRole,
setSigningOrderValue,
type TEnvelopeEditorSurface, type TEnvelopeEditorSurface,
toggleAllowDictateSigners, toggleAllowDictateSigners,
toggleSigningOrder, toggleSigningOrder,
@@ -112,46 +112,71 @@ const runRecipientFlow = async (surface: TEnvelopeEditorSurface): Promise<Recipi
await setRecipientRole(surface.root, 1, 'Needs to approve'); await setRecipientRole(surface.root, 1, 'Needs to approve');
await setRecipientRole(surface.root, 2, 'Receives copy'); await setRecipientRole(surface.root, 2, 'Receives copy');
// The role selects must reflect the change immediately, without requiring a
// navigation or reload (regression: leaf controllers going stale after a
// root-level signers array update).
await assertRecipientRole(surface.root, 1, 'Needs to approve');
await assertRecipientRole(surface.root, 2, 'Receives copy');
await getRecipientRemoveButtons(surface.root).nth(2).click(); await getRecipientRemoveButtons(surface.root).nth(2).click();
await expect(getRecipientEmailInputs(surface.root)).toHaveCount(2); await expect(getRecipientEmailInputs(surface.root)).toHaveCount(2);
await toggleSigningOrder(surface.root, true); await toggleSigningOrder(surface.root, true);
await expect(getSigningOrderInputs(surface.root)).toHaveCount(2); await expect(getRecipientStepCards(surface.root)).toHaveCount(2);
await setSigningOrderValue(surface.root, 0, 2);
// Reordering is drag-only. Pointer-emulated drags are unreliable inside the
// embedded authoring surface (its inner scroll container auto-scrolls and
// cancels the emulated drag), so the drag-swap is exercised on the native
// surfaces only — the same component drives all surfaces.
const shouldSwapViaDrag = !surface.isEmbedded;
if (shouldSwapViaDrag) {
// Let the debounced autosave from the edits above land before dragging —
// the editor re-rendering mid-drag would cancel the drag.
await surface.root.waitForTimeout(1500);
// Drag the first recipient's row into the gap after the last group,
// swapping the two.
await dragRecipientRowToGap(surface.root, 0, 2);
}
await toggleAllowDictateSigners(surface.root, true); await toggleAllowDictateSigners(surface.root, true);
await navigateToAddFieldsAndBack(surface.root); await navigateToAddFieldsAndBack(surface.root);
const [firstRecipient, secondRecipient] = shouldSwapViaDrag
? [TEST_RECIPIENT_VALUES.secondRecipient, primaryRecipient]
: [primaryRecipient, TEST_RECIPIENT_VALUES.secondRecipient];
await expect(getRecipientEmailInputs(surface.root)).toHaveCount(2); await expect(getRecipientEmailInputs(surface.root)).toHaveCount(2);
await expect(getRecipientEmailInputs(surface.root).nth(0)).toHaveValue(TEST_RECIPIENT_VALUES.secondRecipient.email); await expect(getRecipientEmailInputs(surface.root).nth(0)).toHaveValue(firstRecipient.email);
await expect(getRecipientEmailInputs(surface.root).nth(1)).toHaveValue(primaryRecipient.email); await expect(getRecipientEmailInputs(surface.root).nth(1)).toHaveValue(secondRecipient.email);
await expect(getRecipientNameInputs(surface.root).nth(0)).toHaveValue(TEST_RECIPIENT_VALUES.secondRecipient.name); await expect(getRecipientNameInputs(surface.root).nth(0)).toHaveValue(firstRecipient.name);
await expect(getRecipientNameInputs(surface.root).nth(1)).toHaveValue(primaryRecipient.name); await expect(getRecipientNameInputs(surface.root).nth(1)).toHaveValue(secondRecipient.name);
await assertRecipientRole(surface.root, 0, 'Needs to approve'); await assertRecipientRole(surface.root, 0, shouldSwapViaDrag ? 'Needs to approve' : 'Needs to sign');
await assertRecipientRole(surface.root, 1, 'Needs to sign'); await assertRecipientRole(surface.root, 1, shouldSwapViaDrag ? 'Needs to sign' : 'Needs to approve');
await expect(surface.root.locator('#signingOrder')).toHaveAttribute('aria-checked', 'true'); await expect(surface.root.locator('#signingOrder')).toHaveAttribute('aria-checked', 'true');
await expect(surface.root.locator('#allowDictateNextSigner')).toHaveAttribute('aria-checked', 'true'); await expect(surface.root.locator('#allowDictateNextSigner')).toHaveAttribute('aria-checked', 'true');
await expect(getSigningOrderInputs(surface.root).nth(0)).toHaveValue('1'); await expect(surface.root.getByText('Group 1', { exact: true })).toBeVisible();
await expect(getSigningOrderInputs(surface.root).nth(1)).toHaveValue('2'); await expect(surface.root.getByText('Group 2', { exact: true })).toBeVisible();
return { return {
externalId, externalId,
removedRecipientEmail: TEST_RECIPIENT_VALUES.thirdRecipient.email, removedRecipientEmail: TEST_RECIPIENT_VALUES.thirdRecipient.email,
expectedRecipientsBySigningOrder: [ expectedRecipientsBySigningOrder: [
{ {
email: TEST_RECIPIENT_VALUES.secondRecipient.email, email: firstRecipient.email,
name: TEST_RECIPIENT_VALUES.secondRecipient.name, name: firstRecipient.name,
role: RecipientRole.APPROVER, role: shouldSwapViaDrag ? RecipientRole.APPROVER : RecipientRole.SIGNER,
signingOrder: 1, signingOrder: 1,
}, },
{ {
email: primaryRecipient.email, email: secondRecipient.email,
name: primaryRecipient.name, name: secondRecipient.name,
role: RecipientRole.SIGNER, role: shouldSwapViaDrag ? RecipientRole.SIGNER : RecipientRole.APPROVER,
signingOrder: 2, signingOrder: 2,
}, },
], ],
@@ -6,7 +6,7 @@ import { DEFAULT_EMBEDDED_EDITOR_CONFIG } from '@documenso/lib/types/envelope-ed
import { seedBlankDocument } from '@documenso/prisma/seed/documents'; import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates'; import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users'; import { seedUser } from '@documenso/prisma/seed/users';
import type { Page } from '@playwright/test'; import type { Locator, Page } from '@playwright/test';
import { expect } from '@playwright/test'; import { expect } from '@playwright/test';
import { apiSignin } from './authentication'; import { apiSignin } from './authentication';
@@ -264,8 +264,6 @@ export const getRecipientRows = (root: Page) =>
export const getRecipientRemoveButtons = (root: Page) => root.locator('[data-testid="remove-signer-button"]'); 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') => { export const clickEnvelopeEditorStep = async (root: Page, stepId: 'upload' | 'addFields' | 'preview') => {
await root.waitForTimeout(200); await root.waitForTimeout(200);
await root.locator(`[data-testid="envelope-editor-step-${stepId}"]`).first().click(); 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); * Performs a mouse-based drag from a drag handle onto a target element.
await input.fill(value.toString()); *
await input.blur(); * `@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<SweepRecipientRowOverCardResult> => {
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) => { export const persistEmbeddedEnvelope = async (surface: TEnvelopeEditorSurface) => {
@@ -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);
});
@@ -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);
});
@@ -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<ReturnType<typeof seedPendingDocumentWithFullFields>>['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);
});
@@ -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<ReturnType<typeof seedDirectTemplateWithPeer>>) =>
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);
});
@@ -9,7 +9,8 @@ import type { UseFormReturn } from 'react-hook-form';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { z } from 'zod'; 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({ const LocalRecipientSchema = z.object({
formId: z.string().min(1), formId: z.string().min(1),
@@ -65,10 +66,71 @@ export const ZEditorRecipientsFormSchema = z
}); });
} }
}); });
const seenSigningOrders = new Set<number>();
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<typeof ZEditorRecipientsFormSchema>; export type TEditorRecipientsFormSchema = z.infer<typeof ZEditorRecipientsFormSchema>;
/**
* 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<TEditorRecipientsFormSchema>,
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 = { type EditorRecipientsProps = {
envelope: TEditorEnvelope; envelope: TEditorEnvelope;
}; };
@@ -89,19 +151,43 @@ export const useEditorRecipients = ({ envelope }: EditorRecipientsProps): UseEdi
const generateDefaultValues = (options?: ResetFormOptions) => { const generateDefaultValues = (options?: ResetFormOptions) => {
const { recipients, documentMeta } = options ?? {}; 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<number, number | undefined>();
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, id: recipient.id,
formId: String(recipient.id), formId: String(recipient.id),
name: recipient.name, name: recipient.name,
email: recipient.email, email: recipient.email,
role: recipient.role, role: recipient.role,
signingOrder: isCcRecipient(recipient) ? undefined : (recipient.signingOrder ?? index + 1), signingOrder: signingOrderByRecipientId.get(recipient.id),
actionAuth: ZRecipientAuthOptionsSchema.parse(recipient.authOptions)?.actionAuth ?? undefined, actionAuth: ZRecipientAuthOptionsSchema.parse(recipient.authOptions)?.actionAuth ?? undefined,
})); }));
const signers: TLocalRecipient[] = const signers: TLocalRecipient[] =
formRecipients.length > 0 formRecipients.length > 0
? normalizeRecipientSigningOrders(sortRecipientsForSigningOrder(formRecipients)) ? normalizeGroupedSigningOrders(formRecipients)
: [ : [
{ {
formId: initialId, formId: initialId,
@@ -7,7 +7,10 @@ import {
} from '@documenso/lib/types/envelope-editor'; } from '@documenso/lib/types/envelope-editor';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import type { TSetEnvelopeFieldsResponse } from '@documenso/trpc/server/envelope-router/set-envelope-fields.types'; 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 { TUpdateEnvelopeRequest } from '@documenso/trpc/server/envelope-router/update-envelope.types';
import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors'; import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors';
import { getRecipientColor } 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<TUpdateEnvelopeRequest, 'data' | 'meta'>; type UpdateEnvelopePayload = Pick<TUpdateEnvelopeRequest, 'data' | 'meta'>;
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 = { type EnvelopeEditorProviderValue = {
editorConfig: EnvelopeEditorConfig; editorConfig: EnvelopeEditorConfig;
@@ -47,8 +58,8 @@ type EnvelopeEditorProviderValue = {
setLocalEnvelope: (localEnvelope: Partial<TEditorEnvelope>) => void; setLocalEnvelope: (localEnvelope: Partial<TEditorEnvelope>) => void;
updateEnvelope: (envelopeUpdates: UpdateEnvelopePayload) => void; updateEnvelope: (envelopeUpdates: UpdateEnvelopePayload) => void;
updateEnvelopeAsync: (envelopeUpdates: UpdateEnvelopePayload) => Promise<void>; updateEnvelopeAsync: (envelopeUpdates: UpdateEnvelopePayload) => Promise<void>;
setRecipientsDebounced: (recipients: TSetEnvelopeRecipientsRequest['recipients']) => void; setRecipientsDebounced: (recipients: SetRecipientsPayload) => void;
setRecipientsAsync: (recipients: TSetEnvelopeRecipientsRequest['recipients']) => Promise<void>; setRecipientsAsync: (recipients: SetRecipientsPayload) => Promise<void>;
getRecipientColorKey: (recipientId: number) => TRecipientColor; getRecipientColorKey: (recipientId: number) => TRecipientColor;
@@ -170,6 +181,41 @@ export const EnvelopeEditorProvider = ({
const externalFlushCallbacksRef = useRef<Map<string, () => Promise<void>>>(new Map()); const externalFlushCallbacksRef = useRef<Map<string, () => Promise<void>>>(new Map());
const pendingMutationsRef = useRef<Set<Promise<unknown>>>(new Set()); const pendingMutationsRef = useRef<Set<Promise<unknown>>>(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<Map<string, number>>(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<void>) => { const registerExternalFlush = useCallback((key: string, flush: () => Promise<void>) => {
externalFlushCallbacksRef.current.set(key, flush); externalFlushCallbacksRef.current.set(key, flush);
@@ -210,7 +256,7 @@ export const EnvelopeEditorProvider = ({
triggerSave: setRecipientsDebounced, triggerSave: setRecipientsDebounced,
flush: flushSetRecipients, flush: flushSetRecipients,
isPending: isRecipientsMutationPending, isPending: isRecipientsMutationPending,
} = useEnvelopeAutosave(async (localRecipients: TSetEnvelopeRecipientsRequest['recipients']) => { } = useEnvelopeAutosave(async (localRecipients: SetRecipientsPayload) => {
try { try {
let recipients: TEditorEnvelope['recipients'] = []; let recipients: TEditorEnvelope['recipients'] = [];
@@ -220,10 +266,12 @@ export const EnvelopeEditorProvider = ({
const response = await setRecipientsMutation.mutateAsync({ const response = await setRecipientsMutation.mutateAsync({
envelopeId: currentEnvelope.id, envelopeId: currentEnvelope.id,
envelopeType: currentEnvelope.type, envelopeType: currentEnvelope.type,
recipients: localRecipients, recipients: withKnownRecipientIds(localRecipients),
}); });
recipients = response.data; recipients = response.data;
rememberCreatedRecipientIds(response.data);
} else { } else {
recipients = mapLocalRecipientsToRecipients({ envelope: currentEnvelope, localRecipients }); recipients = mapLocalRecipientsToRecipients({ envelope: currentEnvelope, localRecipients });
} }
@@ -252,7 +300,7 @@ export const EnvelopeEditorProvider = ({
} }
}, 1000); }, 1000);
const setRecipientsAsync = async (localRecipients: TSetEnvelopeRecipientsRequest['recipients']) => { const setRecipientsAsync = async (localRecipients: SetRecipientsPayload) => {
setRecipientsDebounced(localRecipients); setRecipientsDebounced(localRecipients);
await flushSetRecipients(); await flushSetRecipients();
}; };
@@ -25,6 +25,7 @@ import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../
import { extractDocumentAuthMethods } from '../../utils/document-auth'; import { extractDocumentAuthMethods } from '../../utils/document-auth';
import type { EnvelopeIdOptions } from '../../utils/envelope'; import type { EnvelopeIdOptions } from '../../utils/envelope';
import { mapSecondaryIdToDocumentId, unsafeBuildEnvelopeIdQuery } from '../../utils/envelope'; import { mapSecondaryIdToDocumentId, unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
import { filterRecipientsInFirstSigningGroup } from '../../utils/recipient-groups';
import { assertRecipientNotExpired } from '../../utils/recipients'; import { assertRecipientNotExpired } from '../../utils/recipients';
import { getIsRecipientsTurnToSign } from '../recipient/get-is-recipient-turn'; import { getIsRecipientsTurnToSign } from '../recipient/get-is-recipient-turn';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
@@ -423,6 +424,7 @@ export const completeDocumentWithToken = async ({
select: { select: {
id: true, id: true,
signingOrder: true, signingOrder: true,
signingStatus: true,
name: true, name: true,
email: true, email: true,
role: true, role: true,
@@ -451,65 +453,87 @@ export const completeDocumentWithToken = async ({
}); });
if (envelope.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL) { 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) => { const currentRecipientOrder = recipient.signingOrder ?? Number.MAX_SAFE_INTEGER;
if (nextSigner && envelope.documentMeta?.allowDictateNextSigner) {
await tx.documentAuditLog.create({ const hasCompletedCurrentStep = nextGroup.every(
data: createDocumentAuditLogData({ (pendingRecipient) => (pendingRecipient.signingOrder ?? Number.MAX_SAFE_INTEGER) > currentRecipientOrder,
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED, );
envelopeId: envelope.id,
user: { if (nextGroup.length > 0 && hasCompletedCurrentStep) {
name: recipientName, // Dictation only applies when advancing to a single-recipient step.
email: recipientEmail, const canDictateNextSigner =
}, Boolean(nextSigner) && Boolean(envelope.documentMeta?.allowDictateNextSigner) && nextGroup.length === 1;
requestMetadata,
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: { data: {
recipientEmail: nextRecipient.email, sendStatus: SendStatus.SENT,
recipientName: nextRecipient.name, sentAt: new Date(),
recipientId: nextRecipient.id, ...(canDictateNextSigner && nextSigner
recipientRole: nextRecipient.role, ? {
changes: [ name: nextSigner.name,
{ email: nextSigner.email,
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 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,
},
});
} }
} }
@@ -38,6 +38,7 @@ import { isDocumentCompleted } from '../../utils/document';
import { extractDocumentAuthMethods } from '../../utils/document-auth'; import { extractDocumentAuthMethods } from '../../utils/document-auth';
import { type EnvelopeIdOptions, mapSecondaryIdToDocumentId } from '../../utils/envelope'; import { type EnvelopeIdOptions, mapSecondaryIdToDocumentId } from '../../utils/envelope';
import { toCheckboxCustomText, toRadioCustomText } from '../../utils/fields'; import { toCheckboxCustomText, toRadioCustomText } from '../../utils/fields';
import { filterRecipientsInFirstSigningGroup, flattenRecipientGroups } from '../../utils/recipient-groups';
import { getRecipientsWithMissingFields, isRecipientEmailValidForSending } from '../../utils/recipients'; import { getRecipientsWithMissingFields, isRecipientEmailValidForSending } from '../../utils/recipients';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf'; 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; 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; let recipientsToNotify = envelope.recipients;
if (signingOrder === DocumentSigningOrder.SEQUENTIAL) { if (signingOrder === DocumentSigningOrder.SEQUENTIAL) {
// Get the currently active recipient. // Get the currently active signing group. Recipients sharing the lowest
recipientsToNotify = envelope.recipients // pending signing order act in parallel within their group.
.filter((r) => r.signingStatus === SigningStatus.NOT_SIGNED && r.role !== RecipientRole.CC) recipientsToNotify = filterRecipientsInFirstSigningGroup(envelope.recipients);
.slice(0, 1);
} }
if (envelope.envelopeItems.length === 0) { if (envelope.envelopeItems.length === 0) {
@@ -38,7 +38,9 @@ import { createDocumentAuthOptions, createRecipientAuthOptions } from '../../uti
import { buildTeamWhereQuery } from '../../utils/teams'; import { buildTeamWhereQuery } from '../../utils/teams';
import { incrementDocumentId, incrementTemplateId } from '../envelope/increment-id'; import { incrementDocumentId, incrementTemplateId } from '../envelope/increment-id';
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits'; 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 { 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 { resolveSignatureLevel } from '../signature-level/resolve-signature-level';
import { getTeamSettings } from '../team/get-team-settings'; import { getTeamSettings } from '../team/get-team-settings';
import { assertUserNotDisabledById } from '../user/assert-user-not-disabled'; import { assertUserNotDisabledById } from '../user/assert-user-not-disabled';
@@ -280,6 +282,32 @@ export const createEnvelope = async ({
assertCompatibleRecipientRole({ signatureLevel, role: recipient.role }); 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 visibility = visibilityOverride || settings.documentVisibility;
const emailId = meta?.emailId; const emailId = meta?.emailId;
@@ -403,21 +431,8 @@ export const createEnvelope = async ({
const firstEnvelopeItem = envelope.envelopeItems[0]; 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( await Promise.all(
allRecipients.map(async (recipient) => { allRecipientsToCreate.map(async (recipient) => {
const recipientAuthOptions = createRecipientAuthOptions({ const recipientAuthOptions = createRecipientAuthOptions({
accessAuth: recipient.accessAuth ?? [], accessAuth: recipient.accessAuth ?? [],
actionAuth: recipient.actionAuth ?? [], actionAuth: recipient.actionAuth ?? [],
@@ -5,13 +5,14 @@ import EnvelopeSchema from '@documenso/prisma/generated/zod/modelSchema/Envelope
import SignatureSchema from '@documenso/prisma/generated/zod/modelSchema/SignatureSchema'; import SignatureSchema from '@documenso/prisma/generated/zod/modelSchema/SignatureSchema';
import TeamSchema from '@documenso/prisma/generated/zod/modelSchema/TeamSchema'; import TeamSchema from '@documenso/prisma/generated/zod/modelSchema/TeamSchema';
import UserSchema from '@documenso/prisma/generated/zod/modelSchema/UserSchema'; 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 { z } from 'zod';
import { AppError, AppErrorCode } from '../../errors/app-error'; import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TDocumentAuthMethods } from '../../types/document-auth'; import type { TDocumentAuthMethods } from '../../types/document-auth';
import { ZEnvelopeFieldSchema, ZFieldSchema } from '../../types/field'; import { ZEnvelopeFieldSchema, ZFieldSchema } from '../../types/field';
import { ZRecipientLiteSchema } from '../../types/recipient'; import { ZRecipientLiteSchema } from '../../types/recipient';
import { isRecipientTurnBySigningOrder } from '../../utils/recipient-groups';
import { isRecipientExpired } from '../../utils/recipients'; import { isRecipientExpired } from '../../utils/recipients';
import { isRecipientAuthorized } from '../document/is-recipient-authorized'; import { isRecipientAuthorized } from '../document/is-recipient-authorized';
import { getTeamSettings } from '../team/get-team-settings'; import { getTeamSettings } from '../team/get-team-settings';
@@ -260,23 +261,9 @@ export const getEnvelopeForRecipientSigning = async ({
}, },
}); });
let isRecipientsTurn = true; const isRecipientsTurn =
envelope.documentMeta.signingOrder !== DocumentSigningOrder.SEQUENTIAL ||
const currentRecipientIndex = envelope.recipients.findIndex((r) => r.token === token); isRecipientTurnBySigningOrder(envelope.recipients, recipient);
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 sender = settings.includeSenderDetails const sender = settings.includeSenderDetails
? { ? {
@@ -1,6 +1,8 @@
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { EnvelopeType, FieldType, RecipientRole, SigningStatus } from '@prisma/client'; import { EnvelopeType, FieldType, RecipientRole, SigningStatus } from '@prisma/client';
import { getLaterSigningStepRecipientsWhereInput } from '../../utils/recipients';
export type GetFieldsForTokenOptions = { export type GetFieldsForTokenOptions = {
token: string; token: string;
}; };
@@ -31,10 +33,11 @@ export const getFieldsForToken = async ({ token }: GetFieldsForTokenOptions) =>
signingStatus: { signingStatus: {
not: SigningStatus.SIGNED, not: SigningStatus.SIGNED,
}, },
signingOrder: {
gte: recipient.signingOrder ?? 0,
},
envelopeId: recipient.envelopeId, 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: { envelope: {
id: recipient.envelopeId, id: recipient.envelopeId,
@@ -1,7 +1,7 @@
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs'; 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 { prisma } from '@documenso/prisma';
import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client'; import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
@@ -25,21 +25,7 @@ export const removeSignedFieldWithToken = async ({
const field = await prisma.field.findFirstOrThrow({ const field = await prisma.field.findFirstOrThrow({
where: { where: {
id: fieldId, id: fieldId,
recipient: { recipient: getFieldOwnerWhereInput(recipient),
...(recipient.role !== RecipientRole.ASSISTANT
? {
id: recipient.id,
}
: {
signingOrder: {
gte: recipient.signingOrder ?? 0,
},
signingStatus: {
not: SigningStatus.SIGNED,
},
envelopeId: recipient.envelopeId,
}),
},
}, },
include: { include: {
envelope: true, envelope: true,
@@ -13,6 +13,7 @@ import { match } from 'ts-pattern';
import { AUTO_SIGNABLE_FIELD_TYPES } from '../../constants/autosign'; import { AUTO_SIGNABLE_FIELD_TYPES } from '../../constants/autosign';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../../constants/date-formats'; import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../../constants/date-formats';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '../../constants/time-zones'; 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 { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import type { TRecipientActionAuth } from '../../types/document-auth'; import type { TRecipientActionAuth } from '../../types/document-auth';
import { import {
@@ -24,7 +25,7 @@ import {
} from '../../types/field-meta'; } from '../../types/field-meta';
import type { RequestMetadata } from '../../universal/extract-request-metadata'; import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs'; 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'; import { validateFieldAuth } from '../document/validate-field-auth';
export type SignFieldWithTokenOptions = { export type SignFieldWithTokenOptions = {
@@ -65,21 +66,7 @@ export const signFieldWithToken = async ({
const field = await prisma.field.findFirstOrThrow({ const field = await prisma.field.findFirstOrThrow({
where: { where: {
id: fieldId, id: fieldId,
recipient: { recipient: getFieldOwnerWhereInput(recipient),
...(recipient.role !== RecipientRole.ASSISTANT
? {
id: recipient.id,
}
: {
signingStatus: {
not: SigningStatus.SIGNED,
},
signingOrder: {
gte: recipient.signingOrder ?? 0,
},
envelopeId: recipient.envelopeId,
}),
},
}, },
include: { include: {
envelope: { envelope: {
@@ -124,6 +111,18 @@ export const signFieldWithToken = async ({
throw new Error(`Field ${fieldId} has no recipientId`); 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) { if (field.type === FieldType.NUMBER && field.fieldMeta) {
const numberFieldParsedMeta = ZNumberFieldMeta.parse(field.fieldMeta); const numberFieldParsedMeta = ZNumberFieldMeta.parse(field.fieldMeta);
const errors = validateNumberField(value, numberFieldParsedMeta, true); const errors = validateNumberField(value, numberFieldParsedMeta, true);
@@ -12,6 +12,7 @@ import type { EnvelopeIdOptions } from '../../utils/envelope';
import { mapRecipientToLegacyRecipient } from '../../utils/recipients'; import { mapRecipientToLegacyRecipient } from '../../utils/recipients';
import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable'; import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; 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'; import { assertCompatibleRecipientRole } from '../signature-level/assert-compatible-recipient-role';
export interface CreateEnvelopeRecipientsOptions { 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) => ({ const normalizedRecipients = recipientsToCreate.map((recipient) => ({
...recipient, ...recipient,
email: recipient.email.toLowerCase(), email: recipient.email.toLowerCase(),
@@ -1,5 +1,7 @@
import { prisma } from '@documenso/prisma'; 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 = { export type GetIsRecipientTurnOptions = {
token: string; token: string;
@@ -17,11 +19,7 @@ export async function getIsRecipientsTurnToSign({ token }: GetIsRecipientTurnOpt
}, },
include: { include: {
documentMeta: true, documentMeta: true,
recipients: { recipients: true,
orderBy: {
signingOrder: 'asc',
},
},
}, },
}); });
@@ -29,24 +27,11 @@ export async function getIsRecipientsTurnToSign({ token }: GetIsRecipientTurnOpt
return true; return true;
} }
const { recipients } = envelope; const currentRecipient = envelope.recipients.find((recipient) => recipient.token === token);
const currentRecipientIndex = recipients.findIndex((r) => r.token === token); if (!currentRecipient) {
if (currentRecipientIndex === -1) {
return false; return false;
} }
for (let i = 0; i < currentRecipientIndex; i++) { return isRecipientTurnBySigningOrder(envelope.recipients, currentRecipient);
// 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;
} }
@@ -1,7 +1,8 @@
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { EnvelopeType, RecipientRole } from '@prisma/client'; import { EnvelopeType } from '@prisma/client';
import { mapDocumentIdToSecondaryId } from '../../utils/envelope'; import { mapDocumentIdToSecondaryId } from '../../utils/envelope';
import { getNextDictatableRecipient } from '../../utils/recipient-groups';
export const getNextPendingRecipient = async ({ export const getNextPendingRecipient = async ({
documentId, documentId,
@@ -16,33 +17,17 @@ export const getNextPendingRecipient = async ({
type: EnvelopeType.DOCUMENT, type: EnvelopeType.DOCUMENT,
secondaryId: mapDocumentIdToSecondaryId(documentId), 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 null;
} }
return { return {
...recipients[currentIndex + 1], ...nextRecipient,
token: '', token: '',
}; };
}; };
@@ -2,6 +2,7 @@ import { prisma } from '@documenso/prisma';
import { FieldType } from '@prisma/client'; import { FieldType } from '@prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error'; import { AppError, AppErrorCode } from '../../errors/app-error';
import { getAssistableRecipientsWhereInput } from '../../utils/recipients';
export interface GetRecipientsForAssistantOptions { export interface GetRecipientsForAssistantOptions {
token: string; token: string;
@@ -23,9 +24,9 @@ export const getRecipientsForAssistant = async ({ token }: GetRecipientsForAssis
let recipients = await prisma.recipient.findMany({ let recipients = await prisma.recipient.findMany({
where: { where: {
envelopeId: assistant.envelopeId, envelopeId: assistant.envelopeId,
signingOrder: { // The assistant themself plus strictly later steps — never their own
gte: assistant.signingOrder ?? 0, // group peers, with null orders treated as the tail step.
}, AND: [getAssistableRecipientsWhereInput(assistant)],
}, },
include: { include: {
fields: { fields: {
@@ -17,6 +17,7 @@ import { type EnvelopeIdOptions, mapSecondaryIdToDocumentId } from '../../utils/
import { canRecipientBeModified, isRecipientEmailValidForSending } from '../../utils/recipients'; import { canRecipientBeModified, isRecipientEmailValidForSending } from '../../utils/recipients';
import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable'; import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; 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'; import { assertCompatibleRecipientRole } from '../signature-level/assert-compatible-recipient-role';
export interface SetDocumentRecipientsOptions { 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) => ({ const normalizedRecipients = recipients.map((recipient) => ({
...recipient, ...recipient,
email: recipient.email.toLowerCase(), email: recipient.email.toLowerCase(),
@@ -12,6 +12,7 @@ import { nanoid } from '../../universal/id';
import { createRecipientAuthOptions } from '../../utils/document-auth'; import { createRecipientAuthOptions } from '../../utils/document-auth';
import { type EnvelopeIdOptions, mapSecondaryIdToTemplateId } from '../../utils/envelope'; import { type EnvelopeIdOptions, mapSecondaryIdToTemplateId } from '../../utils/envelope';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; 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'; import { assertCompatibleRecipientRole } from '../signature-level/assert-compatible-recipient-role';
export type SetTemplateRecipientsOptions = { 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) => { const normalizedRecipients = recipients.map((recipient) => {
// Force replace any changes to the name or email of the direct recipient. // Force replace any changes to the name or email of the direct recipient.
if (envelope.directLink && recipient.id === envelope.directLink.directTemplateRecipientId) { if (envelope.directLink && recipient.id === envelope.directLink.directTemplateRecipientId) {
@@ -14,6 +14,7 @@ import { mapFieldToLegacyField } from '../../utils/fields';
import { canRecipientBeModified } from '../../utils/recipients'; import { canRecipientBeModified } from '../../utils/recipients';
import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable'; import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; 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'; import { assertCompatibleRecipientRole } from '../signature-level/assert-compatible-recipient-role';
export interface UpdateEnvelopeRecipientsOptions { 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 recipientsToUpdate = recipients.map((recipient) => {
const originalRecipient = envelope.recipients.find((existingRecipient) => existingRecipient.id === recipient.id); const originalRecipient = envelope.recipients.find((existingRecipient) => existingRecipient.id === recipient.id);
@@ -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)]);
});
});
});
@@ -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<Pick<Recipient, 'role'> & { 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<number>();
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);
}
};
@@ -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();
});
});
@@ -0,0 +1,57 @@
import type { Recipient } from '@prisma/client';
import { isTspEnvelope } from '../../types/signature-level';
import { isCcRecipient } from '../../utils/recipients';
type AssignDefaultRecipientSigningOrdersOptions<T> = {
signatureLevel: string;
/**
* The recipients supplied by the caller, already validated by
* {@link assertCompatibleRecipientGrouping}.
*/
payloadRecipients: Array<Pick<Recipient, 'role'> & { 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 = <T extends Pick<Recipient, 'role'>>({
signatureLevel,
payloadRecipients,
defaultRecipients,
}: AssignDefaultRecipientSigningOrdersOptions<T>): Array<T & { signingOrder?: number }> => {
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 };
});
};
@@ -40,6 +40,7 @@ import {
extractDocumentAuthMethods, extractDocumentAuthMethods,
} from '../../utils/document-auth'; } from '../../utils/document-auth';
import { mapSecondaryIdToTemplateId } from '../../utils/envelope'; import { mapSecondaryIdToTemplateId } from '../../utils/envelope';
import { filterRecipientsInFirstSigningGroup } from '../../utils/recipient-groups';
import { getRecipientsWithMissingFields } from '../../utils/recipients'; import { getRecipientsWithMissingFields } from '../../utils/recipients';
import { sendDocument } from '../document/send-document'; import { sendDocument } from '../document/send-document';
import { validateFieldAuth } from '../document/validate-field-auth'; import { validateFieldAuth } from '../document/validate-field-auth';
@@ -676,6 +677,7 @@ export const createDocumentFromDirectTemplate = async ({
select: { select: {
id: true, id: true,
signingOrder: true, signingOrder: true,
signingStatus: true,
name: true, name: true,
email: true, email: true,
role: true, role: true,
@@ -694,9 +696,27 @@ export const createDocumentFromDirectTemplate = async ({
orderBy: [{ signingOrder: { sort: 'asc', nulls: 'last' } }, { id: 'asc' }], 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( auditLogsToCreate.push(
createDocumentAuditLogData({ createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED, type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
@@ -730,12 +750,8 @@ export const createDocumentFromDirectTemplate = async ({
await tx.recipient.update({ await tx.recipient.update({
where: { id: nextRecipient.id }, where: { id: nextRecipient.id },
data: { data: {
...(nextSigner && documentMeta?.allowDictateNextSigner name: nextSigner.name,
? { email: nextSigner.email,
name: nextSigner.name,
email: nextSigner.email,
}
: {}),
}, },
}); });
} }
+18
View File
@@ -125,3 +125,21 @@ export type TEnvelopeRecipientLite = z.infer<typeof ZEnvelopeRecipientLiteSchema
export type TEnvelopeRecipientMany = z.infer<typeof ZEnvelopeRecipientManySchema>; export type TEnvelopeRecipientMany = z.infer<typeof ZEnvelopeRecipientManySchema>;
export const ZRecipientEmailSchema = z.union([z.literal(''), zEmail('Invalid email').trim().toLowerCase().max(254)]); 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');
+692
View File
@@ -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();
});
});
+482
View File
@@ -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<Recipient, 'role'> & {
signingOrder?: number | null;
};
export type RecipientStep<T> = {
/**
* 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 = <T extends GroupableRecipient>(recipients: T[]) => {
const ccRecipients = recipients.filter((recipient) => isCcRecipient(recipient));
const nonCcRecipients = recipients.filter((recipient) => !isCcRecipient(recipient));
const membersByOrder = new Map<number, T[]>();
for (const recipient of nonCcRecipients) {
const order = effectiveOrder(recipient);
const members = membersByOrder.get(order) ?? [];
members.push(recipient);
membersByOrder.set(order, members);
}
const steps: RecipientStep<T>[] = [...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 = <T extends GroupableRecipient>(
steps: RecipientStep<T>[],
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 = <T extends GroupableRecipient>(
recipients: T[],
canUpdateRecipient: (recipient: T) => boolean = () => true,
): Array<T & { signingOrder?: number }> => {
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 = <T extends EditorRecipient>(
recipients: T[],
sourceStepIndex: number,
targetStepIndex: number,
canUpdateRecipient?: (recipient: T) => boolean,
): Array<T & { signingOrder?: number }> => {
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 = <T extends EditorRecipient>(
recipients: T[],
formId: string,
targetStepIndex: number,
canUpdateRecipient?: (recipient: T) => boolean,
): Array<T & { signingOrder?: number }> => {
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 = <T extends EditorRecipient>(
recipients: T[],
formId: string,
insertStepIndex: number,
canUpdateRecipient?: (recipient: T) => boolean,
): Array<T & { signingOrder?: number }> => {
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 = <T extends EditorRecipient>(
recipients: T[],
fromStepIndex: number,
toStepIndex: number,
canUpdateRecipient: (recipient: T) => boolean = () => true,
): Array<T & { signingOrder?: number }> => {
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 = <T extends Pick<Recipient, 'id' | 'role'> & { 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<Recipient, 'role' | 'signingStatus'> & {
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 = <T extends SignableRecipient>(
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 = <T extends SignableRecipient>(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 = <T extends SignableRecipient & Pick<Recipient, 'id'>>({
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 = <T extends EditorRecipient>(
recipients: T[],
stepIndex: number,
canUpdateRecipient?: (recipient: T) => boolean,
): Array<T & { signingOrder?: number }> => {
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);
};
+18
View File
@@ -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', () => { it('checks whether the last non-CC recipient is an assistant', () => {
expect( expect(
isAssistantLastSigner([ isAssistantLastSigner([
+125 -5
View File
@@ -1,9 +1,10 @@
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field'; import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
import type { Envelope, Field, Recipient } from '@prisma/client'; import type { Envelope, Field, Prisma, Recipient } from '@prisma/client';
import { RecipientRole, SigningStatus } from '@prisma/client'; import { EnvelopeType, RecipientRole, SigningStatus } from '@prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app'; import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
import { AppError, AppErrorCode } from '../errors/app-error'; import { AppError, AppErrorCode } from '../errors/app-error';
import type { TEditorEnvelope } from '../types/envelope-editor';
import type { TRecipientLite } from '../types/recipient'; import type { TRecipientLite } from '../types/recipient';
import { extractLegacyIds } from '../universal/id'; import { extractLegacyIds } from '../universal/id';
import { zEmail } from './zod'; import { zEmail } from './zod';
@@ -22,11 +23,100 @@ export const isCcRecipient = (recipient: Pick<Recipient, 'role'>) => {
return recipient.role === RecipientRole.CC; return recipient.role === RecipientRole.CC;
}; };
export const isAssistantLastSigner = (recipients: Pick<Recipient, 'role'>[]) => { /**
* 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<Pick<Recipient, 'role'> & { signingOrder?: number | null }>,
) => {
const nonCcRecipients = recipients.filter((recipient) => !isCcRecipient(recipient)); 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<Recipient, 'signingOrder'>,
): 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<Recipient, 'id' | 'signingOrder'>,
): 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<Recipient, 'id' | 'role' | 'signingOrder' | 'envelopeId'>,
): 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 = <T extends RecipientWithSigningOrder>(recipients: T[]): T[] => { export const sortRecipientsForSigningOrder = <T extends RecipientWithSigningOrder>(recipients: T[]): T[] => {
@@ -120,6 +210,36 @@ export const canRecipientBeModified = (
return true; return true;
}; };
/**
* Editor-level wrapper around `canRecipientBeModified`.
*
* Template recipients and unsaved (id-less) recipients can always be modified.
*/
export const canEditorRecipientBeModified = (
envelope: Pick<TEditorEnvelope, 'type' | 'recipients' | 'fields'>,
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. * Whether a recipient can have their fields modified by the document owner.
* *
@@ -19,6 +19,7 @@ import {
ZFieldWidthSchema, ZFieldWidthSchema,
} from '@documenso/lib/types/field'; } from '@documenso/lib/types/field';
import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta'; import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
import { ZRecipientSigningOrderSchema } from '@documenso/lib/types/recipient';
import { zEmail } from '@documenso/lib/utils/zod'; import { zEmail } from '@documenso/lib/utils/zod';
import { RecipientRole } from '@documenso/prisma/client'; import { RecipientRole } from '@documenso/prisma/client';
import { DocumentSigningOrder } from '@documenso/prisma/generated/types'; import { DocumentSigningOrder } from '@documenso/prisma/generated/types';
@@ -36,7 +37,7 @@ export const ZCreateEmbeddingDocumentRequestSchema = z.object({
email: zEmail(), email: zEmail(),
name: z.string(), name: z.string(),
role: z.nativeEnum(RecipientRole), 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 // We have an any cast so any changes here you need to update it in the embeding document edit page
// Search: "map<any>" to find it // Search: "map<any>" to find it
fields: ZFieldAndMetaSchema.and( fields: ZFieldAndMetaSchema.and(
@@ -19,7 +19,7 @@ import {
ZFieldWidthSchema, ZFieldWidthSchema,
} from '@documenso/lib/types/field'; } from '@documenso/lib/types/field';
import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta'; 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 { DocumentSigningOrder, RecipientRole } from '@prisma/client';
import { z } from 'zod'; import { z } from 'zod';
@@ -33,7 +33,7 @@ export const ZCreateEmbeddingTemplateRequestSchema = z.object({
email: ZRecipientEmailSchema, email: ZRecipientEmailSchema,
name: z.string(), name: z.string(),
role: z.nativeEnum(RecipientRole), 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 // We have an any cast so any changes here you need to update it in the embeding document edit page
// Search: "map<any>" to find it // Search: "map<any>" to find it
fields: ZFieldAndMetaSchema.and( fields: ZFieldAndMetaSchema.and(
@@ -19,6 +19,7 @@ import {
ZFieldWidthSchema, ZFieldWidthSchema,
} from '@documenso/lib/types/field'; } from '@documenso/lib/types/field';
import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta'; import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
import { ZRecipientSigningOrderSchema } from '@documenso/lib/types/recipient';
import { zEmail } from '@documenso/lib/utils/zod'; import { zEmail } from '@documenso/lib/utils/zod';
import { DocumentSigningOrder, RecipientRole } from '@documenso/prisma/generated/types'; import { DocumentSigningOrder, RecipientRole } from '@documenso/prisma/generated/types';
import { z } from 'zod'; import { z } from 'zod';
@@ -35,7 +36,7 @@ export const ZUpdateEmbeddingDocumentRequestSchema = z.object({
email: zEmail(), email: zEmail(),
name: z.string(), name: z.string(),
role: z.nativeEnum(RecipientRole), 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 // We have an any cast so any changes here you need to update it in the embeding document edit page
// Search: "map<any>" to find it // Search: "map<any>" to find it
fields: ZFieldAndMetaSchema.and( fields: ZFieldAndMetaSchema.and(
@@ -19,7 +19,7 @@ import {
ZFieldWidthSchema, ZFieldWidthSchema,
} from '@documenso/lib/types/field'; } from '@documenso/lib/types/field';
import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta'; 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 { DocumentSigningOrder, RecipientRole } from '@prisma/client';
import { z } from 'zod'; import { z } from 'zod';
@@ -35,7 +35,7 @@ export const ZUpdateEmbeddingTemplateRequestSchema = z.object({
email: ZRecipientEmailSchema, email: ZRecipientEmailSchema,
name: z.string(), name: z.string(),
role: z.nativeEnum(RecipientRole), 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 // We have an any cast so any changes here you need to update it in the embeding document edit page
// Search: "map<any>" to find it // Search: "map<any>" to find it
fields: ZFieldAndMetaSchema.and( fields: ZFieldAndMetaSchema.and(
@@ -1,5 +1,9 @@
import { ZRecipientAccessAuthTypesSchema, ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-auth'; 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 { RecipientRole } from '@prisma/client';
import { z } from 'zod'; import { z } from 'zod';
@@ -19,7 +23,7 @@ export const ZCreateEnvelopeRecipientSchema = z.object({
email: ZRecipientEmailSchema, email: ZRecipientEmailSchema,
name: z.string().max(255), name: z.string().max(255),
role: z.nativeEnum(RecipientRole), role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(), signingOrder: ZRecipientSigningOrderSchema.optional(),
accessAuth: z.array(ZRecipientAccessAuthTypesSchema).default([]).optional(), accessAuth: z.array(ZRecipientAccessAuthTypesSchema).default([]).optional(),
actionAuth: z.array(ZRecipientActionAuthTypesSchema).default([]).optional(), actionAuth: z.array(ZRecipientActionAuthTypesSchema).default([]).optional(),
}); });
@@ -1,5 +1,9 @@
import { ZRecipientAccessAuthTypesSchema, ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-auth'; 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 { RecipientRole } from '@prisma/client';
import { z } from 'zod'; import { z } from 'zod';
@@ -20,7 +24,7 @@ export const ZUpdateEnvelopeRecipientSchema = z.object({
email: ZRecipientEmailSchema.optional(), email: ZRecipientEmailSchema.optional(),
name: z.string().max(255).optional(), name: z.string().max(255).optional(),
role: z.nativeEnum(RecipientRole).optional(), role: z.nativeEnum(RecipientRole).optional(),
signingOrder: z.number().optional(), signingOrder: ZRecipientSigningOrderSchema.optional(),
accessAuth: z.array(ZRecipientAccessAuthTypesSchema).default([]).optional(), accessAuth: z.array(ZRecipientAccessAuthTypesSchema).default([]).optional(),
actionAuth: z.array(ZRecipientActionAuthTypesSchema).default([]).optional(), actionAuth: z.array(ZRecipientActionAuthTypesSchema).default([]).optional(),
}); });
@@ -1,14 +1,22 @@
import { ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-auth'; 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 { EnvelopeType, RecipientRole } from '@prisma/client';
import { z } from 'zod'; import { z } from 'zod';
export const ZSetEnvelopeRecipientSchema = z.object({ export const ZSetEnvelopeRecipientSchema = z.object({
id: z.number().optional(), 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, email: ZRecipientEmailSchema,
name: z.string().max(255), name: z.string().max(255),
role: z.nativeEnum(RecipientRole), role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(), signingOrder: ZRecipientSigningOrderSchema.optional(),
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]), actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
}); });
@@ -22,7 +30,11 @@ export const ZSetEnvelopeRecipientsResponseSchema = z.object({
data: ZRecipientLiteSchema.omit({ data: ZRecipientLiteSchema.omit({
documentId: true, documentId: true,
templateId: true, templateId: true,
}).array(), })
.extend({
clientId: z.string().nullish(),
})
.array(),
}); });
export type TSetEnvelopeRecipientsRequest = z.infer<typeof ZSetEnvelopeRecipientsRequestSchema>; export type TSetEnvelopeRecipientsRequest = z.infer<typeof ZSetEnvelopeRecipientsRequestSchema>;
@@ -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 { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs'; import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { extractFieldInsertionValues } from '@documenso/lib/utils/envelope-signing'; 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 { prisma } from '@documenso/prisma';
import { DocumentStatus, FieldType, RecipientRole, SigningStatus } from '@prisma/client'; import { DocumentStatus, FieldType, RecipientRole, SigningStatus } from '@prisma/client';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
@@ -39,20 +39,7 @@ export const signEnvelopeFieldRoute = procedure
const field = await prisma.field.findFirst({ const field = await prisma.field.findFirst({
where: { where: {
id: fieldId, id: fieldId,
recipient: recipient: getFieldOwnerWhereInput(recipient),
recipient.role === RecipientRole.ASSISTANT
? {
signingStatus: {
not: SigningStatus.SIGNED,
},
signingOrder: {
gte: recipient.signingOrder ?? 0,
},
envelopeId: recipient.envelopeId,
}
: {
id: recipient.id,
},
}, },
include: { include: {
envelope: { envelope: {
@@ -15,7 +15,7 @@ import {
} from '@documenso/lib/types/document-meta'; } from '@documenso/lib/types/document-meta';
import { ZEnvelopeAttachmentTypeSchema } from '@documenso/lib/types/envelope-attachment'; import { ZEnvelopeAttachmentTypeSchema } from '@documenso/lib/types/envelope-attachment';
import { ZFieldMetaPrefillFieldsSchema } from '@documenso/lib/types/field-meta'; 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 { z } from 'zod';
import { zfd } from 'zod-form-data'; 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.'), id: z.number().describe('The ID of the recipient in the template.'),
email: ZRecipientEmailSchema, email: ZRecipientEmailSchema,
name: z.string().max(255).optional(), name: z.string().max(255).optional(),
signingOrder: z.number().optional(), signingOrder: ZRecipientSigningOrderSchema.optional(),
}), }),
) )
.describe('The information of the recipients to create the document with.') .describe('The information of the recipients to create the document with.')
@@ -5,7 +5,7 @@ import {
ZRecipientActionAuthSchema, ZRecipientActionAuthSchema,
ZRecipientActionAuthTypesSchema, ZRecipientActionAuthTypesSchema,
} from '@documenso/lib/types/document-auth'; } 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 { zEmail } from '@documenso/lib/utils/zod';
import { RecipientRole } from '@prisma/client'; import { RecipientRole } from '@prisma/client';
import { z } from 'zod'; import { z } from 'zod';
@@ -27,7 +27,7 @@ export const ZCreateRecipientSchema = z.object({
email: zEmail().toLowerCase().min(1).max(254), email: zEmail().toLowerCase().min(1).max(254),
name: z.string().max(255), name: z.string().max(255),
role: z.nativeEnum(RecipientRole), role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(), signingOrder: ZRecipientSigningOrderSchema.optional(),
accessAuth: z.array(ZRecipientAccessAuthTypesSchema).default([]).optional(), accessAuth: z.array(ZRecipientAccessAuthTypesSchema).default([]).optional(),
actionAuth: z.array(ZRecipientActionAuthTypesSchema).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(), email: zEmail().toLowerCase().min(1).max(254).optional(),
name: z.string().max(255).optional(), name: z.string().max(255).optional(),
role: z.nativeEnum(RecipientRole).optional(), role: z.nativeEnum(RecipientRole).optional(),
signingOrder: z.number().optional(), signingOrder: ZRecipientSigningOrderSchema.optional(),
accessAuth: z.array(ZRecipientAccessAuthTypesSchema).default([]).optional(), accessAuth: z.array(ZRecipientAccessAuthTypesSchema).default([]).optional(),
actionAuth: z.array(ZRecipientActionAuthTypesSchema).default([]).optional(), actionAuth: z.array(ZRecipientActionAuthTypesSchema).default([]).optional(),
}); });
@@ -86,7 +86,7 @@ export const ZSetDocumentRecipientsRequestSchema = z.object({
email: zEmail().toLowerCase().min(1).max(254), email: zEmail().toLowerCase().min(1).max(254),
name: z.string().max(255), name: z.string().max(255),
role: z.nativeEnum(RecipientRole), role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(), signingOrder: ZRecipientSigningOrderSchema.optional(),
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]), actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
}), }),
), ),
@@ -148,7 +148,7 @@ export const ZSetTemplateRecipientsRequestSchema = z.object({
), ),
name: z.string(), name: z.string(),
role: z.nativeEnum(RecipientRole), role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(), signingOrder: ZRecipientSigningOrderSchema.optional(),
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]), actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
}), }),
), ),