mirror of
https://github.com/documenso/documenso.git
synced 2026-10-01 01:04:38 +10:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f60250c48 | ||
|
|
b75a66d5c0 | ||
|
|
573c928a0e | ||
|
|
be94bddd40 | ||
|
|
5a123be46c |
@@ -120,20 +120,25 @@ Specific parts of the embed can be targeted with CSS classes for granular stylin
|
|||||||
|
|
||||||
### Component Classes
|
### Component Classes
|
||||||
|
|
||||||
| Class | Description |
|
| Class | Description |
|
||||||
| --------------------------------- | --------------------------------------------- |
|
| ---------------------------------------- | --------------------------------------------- |
|
||||||
| `.embed--Root` | Main container for the embedded experience |
|
| `.embed--Root` | Main container for the embedded experience |
|
||||||
| `.embed--DocumentContainer` | Container for the document and signing widget |
|
| `.embed--DocumentContainer` | Container for the document and signing widget |
|
||||||
| `.embed--DocumentViewer` | Container for the document viewer |
|
| `.embed--DocumentViewer` | Container for the document viewer |
|
||||||
| `.embed--DocumentWidget` | The signing widget container |
|
| `.embed--DocumentWidget` | The signing widget container |
|
||||||
| `.embed--DocumentWidgetContainer` | Outer container for the signing widget |
|
| `.embed--DocumentWidgetContainer` | Outer container for the signing widget |
|
||||||
| `.embed--DocumentWidgetHeader` | Header section of the signing widget |
|
| `.embed--DocumentWidgetHeader` | Header section of the signing widget |
|
||||||
| `.embed--DocumentWidgetContent` | Main content area of the signing widget |
|
| `.embed--DocumentWidgetContent` | Main content area of the signing widget |
|
||||||
| `.embed--DocumentWidgetForm` | Form section within the signing widget |
|
| `.embed--DocumentWidgetForm` | Form section within the signing widget |
|
||||||
| `.embed--DocumentWidgetFooter` | Footer section of the signing widget |
|
| `.embed--DocumentWidgetFooter` | Footer section of the signing widget |
|
||||||
| `.embed--WaitingForTurn` | Waiting screen when it is not the user's turn |
|
| `.embed--WaitingForTurn` | Waiting screen when it is not the user's turn |
|
||||||
| `.embed--DocumentCompleted` | Completion screen after signing |
|
| `.embed--DocumentCompleted` | Completion screen after signing |
|
||||||
| `.field--FieldRootContainer` | Base container for document fields |
|
| `.embed--DocumentCompletedCard` | Signature card on the completion screen |
|
||||||
|
| `.embed--DocumentCompletedTitle` | Title on the completion screen |
|
||||||
|
| `.embed--DocumentCompletedStatus` | Status line on the completion screen |
|
||||||
|
| `.embed--DocumentCompletedDescription` | Description text on the completion screen |
|
||||||
|
| `.embed--DocumentRejected` | Rejection screen after rejecting the document |
|
||||||
|
| `.field--FieldRootContainer` | Base container for document fields |
|
||||||
|
|
||||||
### Field Data Attributes
|
### Field Data Attributes
|
||||||
|
|
||||||
@@ -144,6 +149,7 @@ Fields expose data attributes for state-based styling:
|
|||||||
| `[data-field-type]` | `SIGNATURE`, `TEXT`, `CHECKBOX`, `RADIO`, etc. | The type of field |
|
| `[data-field-type]` | `SIGNATURE`, `TEXT`, `CHECKBOX`, `RADIO`, etc. | The type of field |
|
||||||
| `[data-inserted]` | `true`, `false` | Whether the field has been filled |
|
| `[data-inserted]` | `true`, `false` | Whether the field has been filled |
|
||||||
| `[data-validate]` | `true`, `false` | Whether the field is being validated |
|
| `[data-validate]` | `true`, `false` | Whether the field is being validated |
|
||||||
|
| `[data-readonly]` | `true`, `false` | Whether the field is read-only |
|
||||||
|
|
||||||
### Example
|
### Example
|
||||||
|
|
||||||
@@ -155,7 +161,7 @@ Fields expose data attributes for state-based styling:
|
|||||||
|
|
||||||
/* Style filled fields */
|
/* Style filled fields */
|
||||||
.field--FieldRootContainer[data-inserted='true'] {
|
.field--FieldRootContainer[data-inserted='true'] {
|
||||||
background-color: var(--primary);
|
background-color: hsl(var(--primary));
|
||||||
opacity: 0.2;
|
opacity: 0.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import signingCelebration from '@documenso/assets/images/signing-celebration.png
|
|||||||
import { SigningCard3D } from '@documenso/ui/components/signing-card';
|
import { SigningCard3D } from '@documenso/ui/components/signing-card';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { Signature } from '@prisma/client';
|
import type { Signature } from '@prisma/client';
|
||||||
|
import { CheckCircle2Icon } from 'lucide-react';
|
||||||
|
|
||||||
export type EmbedDocumentCompletedPageProps = {
|
export type EmbedDocumentCompletedPageProps = {
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -10,12 +11,8 @@ export type EmbedDocumentCompletedPageProps = {
|
|||||||
|
|
||||||
export const EmbedDocumentCompleted = ({ name, signature }: EmbedDocumentCompletedPageProps) => {
|
export const EmbedDocumentCompleted = ({ name, signature }: EmbedDocumentCompletedPageProps) => {
|
||||||
return (
|
return (
|
||||||
<div className="embed--DocumentCompleted relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
<div className="embed--DocumentCompleted relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center overflow-hidden p-6">
|
||||||
<h3 className="font-semibold text-2xl text-foreground">
|
<div className="embed--DocumentCompletedCard w-full max-w-sm md:max-w-md">
|
||||||
<Trans>Document Completed!</Trans>
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div className="mt-8 w-full max-w-md">
|
|
||||||
<SigningCard3D
|
<SigningCard3D
|
||||||
className="mx-auto w-full"
|
className="mx-auto w-full"
|
||||||
name={name || 'Documenso'}
|
name={name || 'Documenso'}
|
||||||
@@ -24,10 +21,19 @@ export const EmbedDocumentCompleted = ({ name, signature }: EmbedDocumentComplet
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="mt-8 max-w-[50ch] text-center text-muted-foreground text-sm">
|
<h2 className="embed--DocumentCompletedTitle mt-8 max-w-[35ch] text-center font-semibold text-2xl text-foreground leading-normal md:text-3xl">
|
||||||
<Trans>
|
<Trans>Document Completed</Trans>
|
||||||
The document is now completed, please follow any instructions provided within the parent application.
|
</h2>
|
||||||
</Trans>
|
|
||||||
|
<div className="embed--DocumentCompletedStatus mt-4 flex items-center text-center text-documenso-700">
|
||||||
|
<CheckCircle2Icon className="mr-2 h-5 w-5" />
|
||||||
|
<span className="text-sm">
|
||||||
|
<Trans>No further action is required</Trans>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="embed--DocumentCompletedDescription mt-2.5 max-w-[50ch] text-center font-medium text-muted-foreground/60 text-sm md:text-base">
|
||||||
|
<Trans>Please follow any instructions provided within the parent application.</Trans>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export type EmbedSignDocumentV1ClientPageProps = {
|
|||||||
completedFields: DocumentField[];
|
completedFields: DocumentField[];
|
||||||
metadata?: DocumentMeta | null;
|
metadata?: DocumentMeta | null;
|
||||||
isCompleted?: boolean;
|
isCompleted?: boolean;
|
||||||
|
isRejected?: boolean;
|
||||||
hidePoweredBy?: boolean;
|
hidePoweredBy?: boolean;
|
||||||
allowWhitelabelling?: boolean;
|
allowWhitelabelling?: boolean;
|
||||||
allRecipients?: RecipientWithFields[];
|
allRecipients?: RecipientWithFields[];
|
||||||
@@ -70,6 +71,7 @@ export const EmbedSignDocumentV1ClientPage = ({
|
|||||||
completedFields,
|
completedFields,
|
||||||
metadata,
|
metadata,
|
||||||
isCompleted,
|
isCompleted,
|
||||||
|
isRejected,
|
||||||
hidePoweredBy = false,
|
hidePoweredBy = false,
|
||||||
allowWhitelabelling = false,
|
allowWhitelabelling = false,
|
||||||
allRecipients = [],
|
allRecipients = [],
|
||||||
@@ -83,7 +85,9 @@ export const EmbedSignDocumentV1ClientPage = ({
|
|||||||
const [hasFinishedInit, setHasFinishedInit] = useState(false);
|
const [hasFinishedInit, setHasFinishedInit] = useState(false);
|
||||||
const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
|
const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
|
||||||
const [hasCompletedDocument, setHasCompletedDocument] = useState(isCompleted);
|
const [hasCompletedDocument, setHasCompletedDocument] = useState(isCompleted);
|
||||||
const [hasRejectedDocument, setHasRejectedDocument] = useState(recipient.signingStatus === SigningStatus.REJECTED);
|
const [hasRejectedDocument, setHasRejectedDocument] = useState(
|
||||||
|
isRejected ?? recipient.signingStatus === SigningStatus.REJECTED,
|
||||||
|
);
|
||||||
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(
|
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(
|
||||||
allRecipients.length > 0 ? allRecipients[0].id : null,
|
allRecipients.length > 0 ? allRecipients[0].id : null,
|
||||||
);
|
);
|
||||||
@@ -263,6 +267,44 @@ export const EmbedSignDocumentV1ClientPage = ({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!window.parent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasRejectedDocument) {
|
||||||
|
window.parent.postMessage(
|
||||||
|
{
|
||||||
|
action: 'document-rejected',
|
||||||
|
data: {
|
||||||
|
token,
|
||||||
|
documentId,
|
||||||
|
recipientId: recipient.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'*',
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasCompletedDocument) {
|
||||||
|
window.parent.postMessage(
|
||||||
|
{
|
||||||
|
action: 'document-completed',
|
||||||
|
data: {
|
||||||
|
token,
|
||||||
|
documentId,
|
||||||
|
recipientId: recipient.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'*',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasFinishedInit && hasDocumentLoaded && window.parent) {
|
if (hasFinishedInit && hasDocumentLoaded && window.parent) {
|
||||||
window.parent.postMessage(
|
window.parent.postMessage(
|
||||||
|
|||||||
@@ -40,12 +40,18 @@ export const EmbedSignDocumentV2ClientPage = ({
|
|||||||
const [isNameLocked, setIsNameLocked] = useState(false);
|
const [isNameLocked, setIsNameLocked] = useState(false);
|
||||||
const [isEmailLocked, setIsEmailLocked] = useState(envelope.type === EnvelopeType.DOCUMENT && !!email);
|
const [isEmailLocked, setIsEmailLocked] = useState(envelope.type === EnvelopeType.DOCUMENT && !!email);
|
||||||
|
|
||||||
|
// The signing provider's envelope data isn't refreshed on revalidation.
|
||||||
|
const [hasCompletedDocument, setHasCompletedDocument] = useState(isCompleted);
|
||||||
|
const [hasRejectedDocument, setHasRejectedDocument] = useState(isRejected);
|
||||||
|
|
||||||
const onDocumentCompleted = (data: {
|
const onDocumentCompleted = (data: {
|
||||||
token: string;
|
token: string;
|
||||||
documentId: number;
|
documentId: number;
|
||||||
envelopeId: string;
|
envelopeId: string;
|
||||||
recipientId: number;
|
recipientId: number;
|
||||||
}) => {
|
}) => {
|
||||||
|
setHasCompletedDocument(true);
|
||||||
|
|
||||||
if (window.parent) {
|
if (window.parent) {
|
||||||
window.parent.postMessage(
|
window.parent.postMessage(
|
||||||
{
|
{
|
||||||
@@ -112,6 +118,8 @@ export const EmbedSignDocumentV2ClientPage = ({
|
|||||||
recipientId: number;
|
recipientId: number;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
}) => {
|
}) => {
|
||||||
|
setHasRejectedDocument(true);
|
||||||
|
|
||||||
if (window.parent) {
|
if (window.parent) {
|
||||||
window.parent.postMessage(
|
window.parent.postMessage(
|
||||||
{
|
{
|
||||||
@@ -219,23 +227,26 @@ export const EmbedSignDocumentV2ClientPage = ({
|
|||||||
}
|
}
|
||||||
}, [isRejected, envelope.id, recipient.id, recipient.token]);
|
}, [isRejected, envelope.id, recipient.id, recipient.token]);
|
||||||
|
|
||||||
if (isRejected) {
|
if (hasRejectedDocument) {
|
||||||
return <EmbedDocumentRejected />;
|
return <EmbedDocumentRejected />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isCompleted) {
|
if (hasCompletedDocument) {
|
||||||
|
const completedSignature =
|
||||||
|
recipient.fields.find((field) => field.signature)?.signature ?? recipientSignature ?? null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EmbedDocumentCompleted
|
<EmbedDocumentCompleted
|
||||||
name={fullName}
|
name={fullName}
|
||||||
signature={
|
signature={
|
||||||
recipientSignature
|
completedSignature
|
||||||
? {
|
? {
|
||||||
id: 1,
|
id: 1,
|
||||||
fieldId: 1,
|
fieldId: 1,
|
||||||
recipientId: recipient.id,
|
recipientId: recipient.id,
|
||||||
created: new Date(),
|
created: new Date(),
|
||||||
signatureImageAsBase64: recipientSignature.signatureImageAsBase64,
|
signatureImageAsBase64: completedSignature.signatureImageAsBase64,
|
||||||
typedSignature: recipientSignature.typedSignature,
|
typedSignature: completedSignature.typedSignature,
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,10 @@ export const DirectTemplateSigningForm = ({
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sortedRecipients = template.recipients.sort((a, b) => {
|
return 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 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 (
|
||||||
@@ -435,7 +419,7 @@ export const DirectTemplateSigningForm = ({
|
|||||||
fields={localFields}
|
fields={localFields}
|
||||||
fieldsValidated={fieldsValidated}
|
fieldsValidated={fieldsValidated}
|
||||||
recipient={directRecipient}
|
recipient={directRecipient}
|
||||||
allowDictateNextSigner={nextRecipient && template.templateMeta?.allowDictateNextSigner}
|
allowDictateNextSigner={Boolean(nextRecipient && template.templateMeta?.allowDictateNextSigner)}
|
||||||
defaultNextSigner={nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined}
|
defaultNextSigner={nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+4
-2
@@ -102,8 +102,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 ?? '',
|
||||||
@@ -324,7 +326,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,10 @@ export const DocumentSigningForm = ({
|
|||||||
return fieldsRequiringValidation.filter((field) => field.recipientId === recipient.id);
|
return fieldsRequiringValidation.filter((field) => field.recipientId === recipient.id);
|
||||||
}, [fieldsRequiringValidation, recipient]);
|
}, [fieldsRequiringValidation, recipient]);
|
||||||
|
|
||||||
|
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 +159,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 +229,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 +297,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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
+8
-29
@@ -22,7 +22,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';
|
||||||
@@ -60,6 +60,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;
|
||||||
};
|
};
|
||||||
@@ -71,6 +77,7 @@ export const DocumentSigningPageViewV1 = ({
|
|||||||
completedFields,
|
completedFields,
|
||||||
isRecipientsTurn,
|
isRecipientsTurn,
|
||||||
allRecipients = [],
|
allRecipients = [],
|
||||||
|
nextRecipient,
|
||||||
includeSenderDetails,
|
includeSenderDetails,
|
||||||
branding,
|
branding,
|
||||||
}: DocumentSigningPageViewV1Props) => {
|
}: DocumentSigningPageViewV1Props) => {
|
||||||
@@ -133,34 +140,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,8 @@ 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 { isRecipientBefore } from '@documenso/lib/utils/recipients';
|
||||||
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';
|
||||||
@@ -236,12 +238,16 @@ export const EnvelopeSigningProvider = ({
|
|||||||
}, [envelopeData.recipient.fields]);
|
}, [envelopeData.recipient.fields]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Assistant recipients are those that have a signing order after the assistant.
|
* Assistant recipients are those positioned strictly after the assistant —
|
||||||
|
* never their own group peers.
|
||||||
*/
|
*/
|
||||||
const assistantRecipients =
|
const assistantRecipients = useMemo(() => {
|
||||||
recipient.role === RecipientRole.ASSISTANT
|
if (recipient.role !== RecipientRole.ASSISTANT) {
|
||||||
? envelope.recipients.filter((r) => (r.signingOrder ?? 0) > (recipient.signingOrder ?? 0))
|
return [];
|
||||||
: [];
|
}
|
||||||
|
|
||||||
|
return envelope.recipients.filter((r) => isRecipientBefore(recipient, r));
|
||||||
|
}, [envelope.recipients, recipient]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Assistant fields are those fulfill all of the following:
|
* Assistant fields are those fulfill all of the following:
|
||||||
@@ -249,12 +255,11 @@ export const EnvelopeSigningProvider = ({
|
|||||||
* - After the assistant signing order
|
* - After the assistant signing order
|
||||||
* - Are not signature fields
|
* - Are not signature fields
|
||||||
*/
|
*/
|
||||||
const assistantFields =
|
const assistantFields = useMemo(() => {
|
||||||
recipient.role === RecipientRole.ASSISTANT
|
return assistantRecipients
|
||||||
? assistantRecipients
|
.filter((r) => r.signingStatus !== SigningStatus.SIGNED)
|
||||||
.filter((r) => r.signingStatus !== SigningStatus.SIGNED)
|
.flatMap((r) => r.fields.filter((field) => field.type !== FieldType.SIGNATURE));
|
||||||
.flatMap((r) => r.fields.filter((field) => field.type !== FieldType.SIGNATURE))
|
}, [assistantRecipients]);
|
||||||
: [];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The recipient that the assistant has currently selected to sign on behalf of.
|
* The recipient that the assistant has currently selected to sign on behalf of.
|
||||||
@@ -269,7 +274,7 @@ export const EnvelopeSigningProvider = ({
|
|||||||
|
|
||||||
const selectedAssistantRecipientFields = useMemo(() => {
|
const selectedAssistantRecipientFields = useMemo(() => {
|
||||||
return assistantFields.filter((field) => field.recipientId === selectedAssistantRecipient?.id);
|
return assistantFields.filter((field) => field.recipientId === selectedAssistantRecipient?.id);
|
||||||
}, [recipientFields, selectedAssistantRecipient]);
|
}, [assistantFields, selectedAssistantRecipient]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fields that have been completed by other recipients.
|
* Fields that have been completed by other recipients.
|
||||||
@@ -290,32 +295,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 (
|
||||||
|
|||||||
+43
-542
@@ -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 { 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';
|
||||||
@@ -45,7 +33,8 @@ import { isDeepEqual } from 'remeda';
|
|||||||
import { AiFeaturesEnableDialog } from '~/components/dialogs/ai-features-enable-dialog';
|
import { AiFeaturesEnableDialog } from '~/components/dialogs/ai-features-enable-dialog';
|
||||||
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 { useCspNonce } from '~/utils/nonce';
|
|
||||||
|
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 } =
|
||||||
@@ -53,9 +42,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
|
|
||||||
const organisation = useCurrentOrganisation();
|
const organisation = useCurrentOrganisation();
|
||||||
const team = useCurrentTeam();
|
const team = useCurrentTeam();
|
||||||
const cspNonce = useCspNonce();
|
|
||||||
|
|
||||||
const { t } = useLingui();
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { remaining } = useLimits();
|
const { remaining } = useLimits();
|
||||||
const { sessionData } = useOptionalSession();
|
const { sessionData } = useOptionalSession();
|
||||||
@@ -63,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
|
||||||
@@ -109,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;
|
||||||
|
|
||||||
@@ -163,15 +134,16 @@ 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 emptySignerIndex = watchedSigners.findIndex(
|
const emptySignerIndex = watchedSigners.findIndex(
|
||||||
@@ -185,39 +157,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);
|
||||||
@@ -235,20 +190,17 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
email: '',
|
email: '',
|
||||||
role: RecipientRole.SIGNER,
|
role: RecipientRole.SIGNER,
|
||||||
actionAuth: [],
|
actionAuth: [],
|
||||||
signingOrder: activeRecipientCount + 1,
|
signingOrder: undefined,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const onAiDetectionComplete = (detectedRecipients: TDetectedRecipientSchema[]) => {
|
const onAiDetectionComplete = (detectedRecipients: TDetectedRecipientSchema[]) => {
|
||||||
const currentSigners = form.getValues('signers');
|
const currentSigners = form.getValues('signers');
|
||||||
|
|
||||||
let nextSigningOrder =
|
|
||||||
currentSigners.length > 0 ? Math.max(...currentSigners.map((s) => s.signingOrder ?? 0)) + 1 : 1;
|
|
||||||
|
|
||||||
// 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,
|
||||||
@@ -257,10 +209,6 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
actionAuth: [],
|
actionAuth: [],
|
||||||
signingOrder: index + 1,
|
signingOrder: index + 1,
|
||||||
})),
|
})),
|
||||||
{
|
|
||||||
shouldValidate: true,
|
|
||||||
shouldDirty: true,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@@ -281,16 +229,11 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
email: recipient.email,
|
email: recipient.email,
|
||||||
role: recipient.role,
|
role: recipient.role,
|
||||||
actionAuth: [],
|
actionAuth: [],
|
||||||
signingOrder: nextSigningOrder,
|
signingOrder: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
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, {
|
||||||
@@ -304,32 +247,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 ?? '', {
|
||||||
@@ -350,7 +267,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
email: currentEditorEmail ?? '',
|
email: currentEditorEmail ?? '',
|
||||||
role: RecipientRole.SIGNER,
|
role: RecipientRole.SIGNER,
|
||||||
actionAuth: [],
|
actionAuth: [],
|
||||||
signingOrder: activeRecipientCount + 1,
|
signingOrder: undefined,
|
||||||
},
|
},
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
@@ -359,142 +276,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);
|
||||||
|
|
||||||
@@ -506,10 +287,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,
|
||||||
@@ -537,14 +316,17 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
|
|
||||||
const { data } = validatedFormValues;
|
const { data } = validatedFormValues;
|
||||||
|
|
||||||
// Weird edge case where the whole envelope is created via API
|
// Locked recipients hold persisted values the server refuses to rewrite,
|
||||||
// with no signing order. If they come to this page it will show an error
|
// e.g. an envelope created via API with no signing order where a recipient
|
||||||
// since they aren't equal and the recipient is no longer editable.
|
// has already signed. Restore their PERSISTED order so form normalization
|
||||||
|
// drift never submits a "changed" locked recipient the server rejects.
|
||||||
const envelopeRecipients = data.signers.map((recipient) => {
|
const envelopeRecipients = data.signers.map((recipient) => {
|
||||||
if (!canRecipientBeModified(recipient.id)) {
|
if (!canRecipientBeModified(recipient.id)) {
|
||||||
|
const persistedRecipient = recipients.find((envelopeRecipient) => envelopeRecipient.id === recipient.id);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...recipient,
|
...recipient,
|
||||||
signingOrder: recipient.signingOrder,
|
signingOrder: persistedRecipient?.signingOrder ?? undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return recipient;
|
return recipient;
|
||||||
@@ -570,7 +352,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
signer.email !== recipient.email ||
|
signer.email !== recipient.email ||
|
||||||
signer.name !== recipient.name ||
|
signer.name !== recipient.name ||
|
||||||
signer.role !== recipient.role ||
|
signer.role !== recipient.role ||
|
||||||
signer.signingOrder !== recipient.signingOrder ||
|
(signer.signingOrder ?? null) !== (recipient.signingOrder ?? null) ||
|
||||||
!isDeepEqual(signerActionAuth, recipientActionAuth)
|
!isDeepEqual(signerActionAuth, recipientActionAuth)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -590,7 +372,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">
|
||||||
@@ -646,7 +428,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" />
|
||||||
@@ -796,288 +578,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DragDropContext
|
<RecipientStepList showAdvancedSettings={showAdvancedSettings} />
|
||||||
nonce={cspNonce}
|
|
||||||
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,235 @@
|
|||||||
|
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"
|
||||||
|
aria-label={t`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"
|
||||||
|
aria-label={t`Name`}
|
||||||
|
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,261 @@
|
|||||||
|
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 { Plural, 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);
|
||||||
|
|
||||||
|
const stepLabel = step.order ?? stepIndex + 1;
|
||||||
|
|
||||||
|
// 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 {stepLabel}</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" />
|
||||||
|
<Plural
|
||||||
|
value={step.members.length}
|
||||||
|
one="# recipient · any order"
|
||||||
|
other="# recipients · any order"
|
||||||
|
/>
|
||||||
|
</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,398 @@
|
|||||||
|
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,
|
||||||
|
isSigningOrderFrozen,
|
||||||
|
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 { useCspNonce } from '~/utils/nonce';
|
||||||
|
|
||||||
|
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 cspNonce = useCspNonce();
|
||||||
|
|
||||||
|
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 isOrderingFrozen = useMemo(
|
||||||
|
() => isSigningOrderFrozen(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 nonce={cspNonce} 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 = isOrderingFrozen || 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -5,8 +5,8 @@ import { cn } from '@documenso/ui/lib/utils';
|
|||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
import { Trans, useLingui } from '@lingui/react/macro';
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
import pMap from 'p-map';
|
import pMap from 'p-map';
|
||||||
import * as pdfjsLib from 'pdfjs-dist';
|
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs';
|
||||||
import pdfjsWorker from 'pdfjs-dist/build/pdf.worker?url';
|
import pdfjsWorker from 'pdfjs-dist/legacy/build/pdf.worker.mjs?url';
|
||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ export function LayoutContent({ children }: { children: React.ReactNode }) {
|
|||||||
<style
|
<style
|
||||||
nonce={nonce(cspNonce)}
|
nonce={nonce(cspNonce)}
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: `*, *::before, *::after { animation: none !important; transition: none !important; }`,
|
__html: `*, *::before, *::after { animation: none !important; transition-duration: 0.001s !important; transition-delay: 0s !important; }`,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
|||||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||||
import { isRecipientExpired } from '@documenso/lib/utils/recipients';
|
import { isRecipientExpired } from '@documenso/lib/utils/recipients';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { RecipientRole } from '@prisma/client';
|
import { RecipientRole, SigningStatus } from '@prisma/client';
|
||||||
import { data } from 'react-router';
|
import { data } from 'react-router';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
@@ -80,7 +80,11 @@ async function handleV1Loader({ params, request }: Route.LoaderArgs) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isRecipientExpired(recipient)) {
|
const isCompleted = recipient.signingStatus === SigningStatus.SIGNED || isDocumentCompleted(document.status);
|
||||||
|
const isRejected = recipient.signingStatus === SigningStatus.REJECTED;
|
||||||
|
const hasRecipientActioned = isCompleted || isRejected;
|
||||||
|
|
||||||
|
if (!hasRecipientActioned && isRecipientExpired(recipient)) {
|
||||||
throw data(
|
throw data(
|
||||||
{
|
{
|
||||||
type: 'embed-recipient-expired',
|
type: 'embed-recipient-expired',
|
||||||
@@ -115,7 +119,7 @@ async function handleV1Loader({ params, request }: Route.LoaderArgs) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isRecipientsTurnToSign = await getIsRecipientsTurnToSign({ token });
|
const isRecipientsTurnToSign = hasRecipientActioned || (await getIsRecipientsTurnToSign({ token }));
|
||||||
|
|
||||||
if (!isRecipientsTurnToSign) {
|
if (!isRecipientsTurnToSign) {
|
||||||
throw data(
|
throw data(
|
||||||
@@ -173,6 +177,8 @@ async function handleV1Loader({ params, request }: Route.LoaderArgs) {
|
|||||||
recipient,
|
recipient,
|
||||||
fields,
|
fields,
|
||||||
completedFields,
|
completedFields,
|
||||||
|
isCompleted,
|
||||||
|
isRejected,
|
||||||
hidePoweredBy,
|
hidePoweredBy,
|
||||||
allowEmbedSigningWhitelabel,
|
allowEmbedSigningWhitelabel,
|
||||||
};
|
};
|
||||||
@@ -392,6 +398,8 @@ const EmbedSignDocumentPageV1 = ({ data }: { data: Awaited<ReturnType<typeof han
|
|||||||
recipient,
|
recipient,
|
||||||
fields,
|
fields,
|
||||||
completedFields,
|
completedFields,
|
||||||
|
isCompleted,
|
||||||
|
isRejected,
|
||||||
hidePoweredBy,
|
hidePoweredBy,
|
||||||
allowEmbedSigningWhitelabel,
|
allowEmbedSigningWhitelabel,
|
||||||
} = data;
|
} = data;
|
||||||
@@ -415,7 +423,8 @@ const EmbedSignDocumentPageV1 = ({ data }: { data: Awaited<ReturnType<typeof han
|
|||||||
fields={fields}
|
fields={fields}
|
||||||
completedFields={completedFields}
|
completedFields={completedFields}
|
||||||
metadata={document.documentMeta}
|
metadata={document.documentMeta}
|
||||||
isCompleted={isDocumentCompleted(document.status)}
|
isCompleted={isCompleted}
|
||||||
|
isRejected={isRejected}
|
||||||
hidePoweredBy={hidePoweredBy}
|
hidePoweredBy={hidePoweredBy}
|
||||||
allowWhitelabelling={allowEmbedSigningWhitelabel}
|
allowWhitelabelling={allowEmbedSigningWhitelabel}
|
||||||
allRecipients={allRecipients}
|
allRecipients={allRecipients}
|
||||||
|
|||||||
@@ -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,71 @@
|
|||||||
|
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". Negatives were persisted as-is and sort
|
||||||
|
* ahead of everything, including the `?? 0` fallback in assistant scoping.
|
||||||
|
* Zero remains valid for legacy handling.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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 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,118 @@
|
|||||||
|
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]: numbers recipients created without a signing order on a QES envelope', async ({ request }) => {
|
||||||
|
const { envelopeId, token } = await seedEnvelopeAtSignatureLevel(request, 'QES');
|
||||||
|
|
||||||
|
const response = await createRecipients(request, token, envelopeId, [{}, { signingOrder: 3 }, {}]);
|
||||||
|
|
||||||
|
expect(response.ok(), await response.text()).toBeTruthy();
|
||||||
|
|
||||||
|
const recipients = await prisma.recipient.findMany({ where: { envelopeId }, orderBy: { id: 'asc' } });
|
||||||
|
|
||||||
|
expect(recipients.map((recipient) => recipient.signingOrder)).toEqual([4, 3, 5]);
|
||||||
|
|
||||||
|
const second = await createRecipients(request, token, envelopeId, [{}]);
|
||||||
|
|
||||||
|
expect(second.ok(), await second.text()).toBeTruthy();
|
||||||
|
|
||||||
|
const recipientsAfter = await prisma.recipient.findMany({ where: { envelopeId }, orderBy: { id: 'asc' } });
|
||||||
|
|
||||||
|
expect(recipientsAfter.map((recipient) => recipient.signingOrder)).toEqual([4, 3, 5, 6]);
|
||||||
|
});
|
||||||
|
|
||||||
|
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,250 @@
|
|||||||
|
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();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assistant scoping must follow the same position model as signing (numbered
|
||||||
|
* first, then unordered by id). Historically `signingOrder ?? 0` treated an
|
||||||
|
* unordered assistant as FIRST, letting their token prefill every ordered
|
||||||
|
* recipient's fields.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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 assistant cannot assist an ordered recipient', async () => {
|
||||||
|
const { assistant, signerTextField } = await seedAssistantDocument({
|
||||||
|
assistantOrder: null,
|
||||||
|
signerOrder: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const assistableRecipients = await getRecipientsForAssistant({ token: assistant.token });
|
||||||
|
|
||||||
|
expect(assistableRecipients.map((recipient) => recipient.id)).toEqual([assistant.id]);
|
||||||
|
|
||||||
|
const fields = await getFieldsForToken({ token: assistant.token });
|
||||||
|
|
||||||
|
expect(fields.map((field) => field.id)).not.toContain(signerTextField.id);
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[ASSISTANT_NULL_ORDER]: a null-order assistant can assist a null-order recipient created after them', async () => {
|
||||||
|
const { assistant, signer, signerTextField } = await seedAssistantDocument({
|
||||||
|
assistantOrder: null,
|
||||||
|
signerOrder: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const assistableRecipients = await getRecipientsForAssistant({ token: assistant.token });
|
||||||
|
|
||||||
|
expect(assistableRecipients.map((recipient) => recipient.id)).toEqual([assistant.id, signer.id]);
|
||||||
|
|
||||||
|
const fields = await getFieldsForToken({ token: assistant.token });
|
||||||
|
|
||||||
|
expect(fields.map((field) => field.id)).toContain(signerTextField.id);
|
||||||
|
|
||||||
|
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 recipient cannot be assisted by a null-order assistant created after them', async () => {
|
||||||
|
const { user, team } = await seedUser();
|
||||||
|
const { user: signerUser } = await seedUser();
|
||||||
|
const { user: assistantUser } = await seedUser();
|
||||||
|
|
||||||
|
const { recipients } = await seedPendingDocumentWithFullFields({
|
||||||
|
owner: user,
|
||||||
|
teamId: team.id,
|
||||||
|
recipients: [signerUser, assistantUser],
|
||||||
|
recipientsCreateOptions: [
|
||||||
|
{ signingOrder: null, role: RecipientRole.SIGNER },
|
||||||
|
{ signingOrder: null, role: RecipientRole.ASSISTANT },
|
||||||
|
],
|
||||||
|
fields: [FieldType.TEXT],
|
||||||
|
updateDocumentOptions: {
|
||||||
|
documentMeta: {
|
||||||
|
upsert: {
|
||||||
|
create: { signingOrder: DocumentSigningOrder.SEQUENTIAL },
|
||||||
|
update: { signingOrder: DocumentSigningOrder.SEQUENTIAL },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const assistant = recipients.find((recipient) => recipient.role === RecipientRole.ASSISTANT);
|
||||||
|
const signerTextField = recipients
|
||||||
|
.find((recipient) => recipient.role === RecipientRole.SIGNER)
|
||||||
|
?.fields.find((field) => field.type === FieldType.TEXT);
|
||||||
|
|
||||||
|
if (!assistant || !signerTextField) {
|
||||||
|
throw new Error('Seeded recipients not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const assistableRecipients = await getRecipientsForAssistant({ token: assistant.token });
|
||||||
|
|
||||||
|
expect(assistableRecipients.map((recipient) => recipient.id)).toEqual([assistant.id]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
signFieldWithToken({
|
||||||
|
token: assistant.token,
|
||||||
|
fieldId: signerTextField.id,
|
||||||
|
value: 'TEXT',
|
||||||
|
}),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
+81
@@ -0,0 +1,81 @@
|
|||||||
|
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, SigningStatus } from '@prisma/client';
|
||||||
|
|
||||||
|
import { apiSignin } from '../fixtures/authentication';
|
||||||
|
import {
|
||||||
|
clickAddSignerButton,
|
||||||
|
getRecipientEmailInputs,
|
||||||
|
getRecipientStepCards,
|
||||||
|
setRecipientEmail,
|
||||||
|
} from '../fixtures/envelope-editor';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Numbers sort ahead of unordered rows, so numbering anything behind a signed
|
||||||
|
* unordered recipient would move it in front of them.
|
||||||
|
*/
|
||||||
|
|
||||||
|
test('[LEGACY_UNORDERED_TAIL]: additions queue behind a locked unordered recipient', async ({ page }) => {
|
||||||
|
const { user, team } = await seedUser();
|
||||||
|
const { user: alice } = await seedUser();
|
||||||
|
const { user: bob } = await seedUser();
|
||||||
|
|
||||||
|
const { document } = await seedPendingDocumentWithFullFields({
|
||||||
|
owner: user,
|
||||||
|
teamId: team.id,
|
||||||
|
recipients: [alice, bob],
|
||||||
|
recipientsCreateOptions: [
|
||||||
|
{ signingOrder: null, signingStatus: SigningStatus.SIGNED, sendStatus: SendStatus.SENT },
|
||||||
|
{ signingOrder: null, signingStatus: SigningStatus.NOT_SIGNED, sendStatus: SendStatus.NOT_SENT },
|
||||||
|
],
|
||||||
|
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(getRecipientEmailInputs(page)).toHaveCount(2);
|
||||||
|
await expect(getRecipientStepCards(page)).toHaveCount(2);
|
||||||
|
|
||||||
|
await expect(getRecipientEmailInputs(page).nth(0)).toHaveValue(alice.email);
|
||||||
|
await expect(getRecipientEmailInputs(page).nth(1)).toHaveValue(bob.email);
|
||||||
|
|
||||||
|
await clickAddSignerButton(page);
|
||||||
|
await setRecipientEmail(page, 2, 'carol@example.com');
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const recipients = await prisma.recipient.findMany({ where: { envelopeId: document.id } });
|
||||||
|
|
||||||
|
return recipients.find((recipient) => recipient.email === 'carol@example.com')?.id ?? null;
|
||||||
|
})
|
||||||
|
.not.toBeNull();
|
||||||
|
|
||||||
|
const recipients = await prisma.recipient.findMany({
|
||||||
|
where: { envelopeId: document.id },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(recipients.map((recipient) => [recipient.email, recipient.signingOrder])).toEqual([
|
||||||
|
[alice.email, null],
|
||||||
|
[bob.email, null],
|
||||||
|
['carol@example.com', null],
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(getRecipientStepCards(page)).toHaveCount(3);
|
||||||
|
await expect(getRecipientEmailInputs(page).nth(2)).toHaveValue('carol@example.com');
|
||||||
|
});
|
||||||
@@ -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/);
|
||||||
|
});
|
||||||
+98
@@ -0,0 +1,98 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The editor must not invent an order from array position: the guess can land
|
||||||
|
* on a real order (a signing step) or move the recipient ahead of one 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,129 @@
|
|||||||
|
import { completeDocumentWithToken } from '@documenso/lib/server-only/document/complete-document-with-token';
|
||||||
|
import { getIsRecipientsTurnToSign } from '@documenso/lib/server-only/recipient/get-is-recipient-turn';
|
||||||
|
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';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sequential documents created before automatic numbering hold recipients
|
||||||
|
* with a NULL `signingOrder`, processed one at a time in id order.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
|
const seedLegacySequentialDocument = async (options: {
|
||||||
|
signingOrders: Array<number | null>;
|
||||||
|
signatureLevel?: string;
|
||||||
|
}) => {
|
||||||
|
const { user, team } = await seedUser();
|
||||||
|
const signers = await Promise.all(options.signingOrders.map(async () => (await seedUser()).user));
|
||||||
|
|
||||||
|
const { recipients } = await seedPendingDocumentWithFullFields({
|
||||||
|
owner: user,
|
||||||
|
teamId: team.id,
|
||||||
|
recipients: signers,
|
||||||
|
recipientsCreateOptions: options.signingOrders.map((signingOrder, index) => ({
|
||||||
|
signingOrder,
|
||||||
|
sendStatus: index === 0 ? SendStatus.SENT : SendStatus.NOT_SENT,
|
||||||
|
})),
|
||||||
|
fields: [],
|
||||||
|
updateDocumentOptions: {
|
||||||
|
signatureLevel: options.signatureLevel,
|
||||||
|
documentMeta: {
|
||||||
|
upsert: {
|
||||||
|
create: { signingOrder: DocumentSigningOrder.SEQUENTIAL },
|
||||||
|
update: { signingOrder: DocumentSigningOrder.SEQUENTIAL },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const byId = [...recipients].sort((a, b) => a.id - b.id);
|
||||||
|
|
||||||
|
return { first: byId[0], second: byId[1], third: byId[2] };
|
||||||
|
};
|
||||||
|
|
||||||
|
test('[LEGACY_UNORDERED]: unordered recipients take turns by id and are invited one at a time', async () => {
|
||||||
|
const { first, second, third } = await seedLegacySequentialDocument({ signingOrders: [null, null, null] });
|
||||||
|
|
||||||
|
expect(await getIsRecipientsTurnToSign({ token: first.token })).toBe(true);
|
||||||
|
expect(await getIsRecipientsTurnToSign({ token: second.token })).toBe(false);
|
||||||
|
expect(await getIsRecipientsTurnToSign({ token: third.token })).toBe(false);
|
||||||
|
|
||||||
|
await completeDocumentWithToken({
|
||||||
|
token: first.token,
|
||||||
|
id: { type: 'envelopeId', id: first.envelopeId },
|
||||||
|
});
|
||||||
|
|
||||||
|
const secondAfter = await prisma.recipient.findUniqueOrThrow({ where: { id: second.id } });
|
||||||
|
const thirdAfter = await prisma.recipient.findUniqueOrThrow({ where: { id: third.id } });
|
||||||
|
|
||||||
|
expect(secondAfter.sendStatus).toBe(SendStatus.SENT);
|
||||||
|
await expectSigningRequestJobCount(second.id, 1);
|
||||||
|
|
||||||
|
expect(thirdAfter.sendStatus).toBe(SendStatus.NOT_SENT);
|
||||||
|
await expectSigningRequestJobCount(third.id, 0);
|
||||||
|
|
||||||
|
expect(await getIsRecipientsTurnToSign({ token: second.token })).toBe(true);
|
||||||
|
expect(await getIsRecipientsTurnToSign({ token: third.token })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[LEGACY_UNORDERED]: numbered recipients sign before unordered ones', async () => {
|
||||||
|
const { first, second, third } = await seedLegacySequentialDocument({ signingOrders: [null, null, 1] });
|
||||||
|
|
||||||
|
expect(await getIsRecipientsTurnToSign({ token: third.token })).toBe(true);
|
||||||
|
expect(await getIsRecipientsTurnToSign({ token: first.token })).toBe(false);
|
||||||
|
|
||||||
|
await completeDocumentWithToken({
|
||||||
|
token: third.token,
|
||||||
|
id: { type: 'envelopeId', id: third.envelopeId },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expectSigningRequestJobCount(first.id, 1);
|
||||||
|
await expectSigningRequestJobCount(second.id, 0);
|
||||||
|
|
||||||
|
expect(await getIsRecipientsTurnToSign({ token: first.token })).toBe(true);
|
||||||
|
expect(await getIsRecipientsTurnToSign({ token: second.token })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[LEGACY_UNORDERED]: an AES envelope sequences a legacy duplicate order one recipient at a time', async () => {
|
||||||
|
const { first, second } = await seedLegacySequentialDocument({
|
||||||
|
signingOrders: [1, 1],
|
||||||
|
signatureLevel: 'AES',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await getIsRecipientsTurnToSign({ token: first.token })).toBe(true);
|
||||||
|
expect(await getIsRecipientsTurnToSign({ token: second.token })).toBe(false);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
completeDocumentWithToken({
|
||||||
|
token: second.token,
|
||||||
|
id: { type: 'envelopeId', id: second.envelopeId },
|
||||||
|
}),
|
||||||
|
).rejects.toThrow();
|
||||||
|
|
||||||
|
await completeDocumentWithToken({
|
||||||
|
token: first.token,
|
||||||
|
id: { type: 'envelopeId', id: first.envelopeId },
|
||||||
|
});
|
||||||
|
|
||||||
|
const secondAfter = await prisma.recipient.findUniqueOrThrow({ where: { id: second.id } });
|
||||||
|
|
||||||
|
expect(secondAfter.sendStatus).toBe(SendStatus.SENT);
|
||||||
|
await expectSigningRequestJobCount(second.id, 1);
|
||||||
|
expect(await getIsRecipientsTurnToSign({ token: second.token })).toBe(true);
|
||||||
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
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. Nor may the flow advance PAST them: the turn check treats a
|
||||||
|
* rejection as blocking, so a recipient in a later step would receive a
|
||||||
|
* signing request whose link redirects to /waiting and whose completion is
|
||||||
|
* refused. (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]: no step is activated past a rejected recipient', 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 later step is NOT activated either: the rejection blocks the flow
|
||||||
|
// (the turn check would refuse their completion), and the envelope is
|
||||||
|
// heading to REJECTED via the seal job. Emailing them would invite a
|
||||||
|
// signing session that can only dead-end at /waiting.
|
||||||
|
expect(laterAfter.sentAt).toBeNull();
|
||||||
|
await expectSigningRequestJobCount(later.id, 0);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -3,13 +3,14 @@ import { ZRecipientActionAuthTypesSchema, ZRecipientAuthOptionsSchema } from '@d
|
|||||||
import type { TEditorEnvelope } from '@documenso/lib/types/envelope-editor';
|
import type { TEditorEnvelope } from '@documenso/lib/types/envelope-editor';
|
||||||
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
|
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
|
import { DocumentSigningOrder, EnvelopeType, RecipientRole } from '@prisma/client';
|
||||||
import { useId } from 'react';
|
import { useId } from 'react';
|
||||||
import type { UseFormReturn } from 'react-hook-form';
|
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 { canRecipientBeModified, 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,72 @@ 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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fully set signers array to populate the rest of the values.
|
||||||
|
form.setValue('signers', updatedSigners, {
|
||||||
|
shouldValidate: true,
|
||||||
|
shouldDirty: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
type EditorRecipientsProps = {
|
type EditorRecipientsProps = {
|
||||||
envelope: TEditorEnvelope;
|
envelope: TEditorEnvelope;
|
||||||
};
|
};
|
||||||
@@ -89,19 +152,41 @@ 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);
|
||||||
|
|
||||||
|
// Locked recipients hold persisted values the server refuses to rewrite.
|
||||||
|
// Initialization must never assign or renumber their signing orders —
|
||||||
|
// doing so makes the very first autosave submit a "changed" locked
|
||||||
|
// recipient, which the server rejects on every subsequent save.
|
||||||
|
const isRecipientLocked = (recipientId: number) => {
|
||||||
|
if (envelope.type === EnvelopeType.TEMPLATE) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const persistedRecipient = sourceRecipients.find((recipient) => recipient.id === recipientId);
|
||||||
|
|
||||||
|
if (!persistedRecipient) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !canRecipientBeModified(persistedRecipient, envelope.fields);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Persisted orders round-trip as-is; `normalizeGroupedSigningOrders`
|
||||||
|
// decides whether an unordered recipient can be numbered.
|
||||||
|
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: isCcRecipient(recipient) ? undefined : (recipient.signingOrder ?? undefined),
|
||||||
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, (formRecipient) => !isRecipientLocked(formRecipient.id))
|
||||||
: [
|
: [
|
||||||
{
|
{
|
||||||
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';
|
||||||
@@ -30,6 +33,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;
|
||||||
|
|
||||||
@@ -48,8 +59,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;
|
||||||
|
|
||||||
@@ -172,6 +183,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);
|
||||||
|
|
||||||
@@ -212,7 +258,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'] = [];
|
||||||
|
|
||||||
@@ -222,10 +268,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 });
|
||||||
}
|
}
|
||||||
@@ -260,7 +308,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();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,11 +21,13 @@ import { AppError, AppErrorCode } from '../../errors/app-error';
|
|||||||
import { jobs } from '../../jobs/client';
|
import { jobs } from '../../jobs/client';
|
||||||
import type { TRecipientAccessAuth } from '../../types/document-auth';
|
import type { TRecipientAccessAuth } from '../../types/document-auth';
|
||||||
import { DocumentAuth } from '../../types/document-auth';
|
import { DocumentAuth } from '../../types/document-auth';
|
||||||
|
import { isTspEnvelope } from '../../types/signature-level';
|
||||||
import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../types/webhook-payload';
|
import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../types/webhook-payload';
|
||||||
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 { assertRecipientNotExpired } from '../../utils/recipients';
|
import { getRecipientsInActiveSigningStep, isRecipientTurnBySigningOrder } from '../../utils/recipient-groups';
|
||||||
|
import { assertRecipientNotExpired, isRecipientBefore } 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';
|
||||||
import { isRecipientAuthorized } from './is-recipient-authorized';
|
import { isRecipientAuthorized } from './is-recipient-authorized';
|
||||||
@@ -56,7 +58,7 @@ export const completeDocumentWithToken = async ({
|
|||||||
userId,
|
userId,
|
||||||
accessAuthOptions,
|
accessAuthOptions,
|
||||||
requestMetadata,
|
requestMetadata,
|
||||||
nextSigner,
|
nextSigner: dictatedNextSigner,
|
||||||
recipientOverride,
|
recipientOverride,
|
||||||
}: CompleteDocumentWithTokenOptions) => {
|
}: CompleteDocumentWithTokenOptions) => {
|
||||||
const envelope = await prisma.envelope.findFirst({
|
const envelope = await prisma.envelope.findFirst({
|
||||||
@@ -423,6 +425,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 +454,100 @@ export const completeDocumentWithToken = async ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (envelope.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL) {
|
if (envelope.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL) {
|
||||||
const [nextRecipient] = pendingRecipients;
|
const sequencing = { strictlySequential: isTspEnvelope(envelope) };
|
||||||
|
|
||||||
await prisma.$transaction(async (tx) => {
|
const nextRecipients = getRecipientsInActiveSigningStep(pendingRecipients, sequencing);
|
||||||
if (nextSigner && envelope.documentMeta?.allowDictateNextSigner) {
|
|
||||||
await tx.documentAuditLog.create({
|
// Peers still pending in the current step are not the "next" step:
|
||||||
data: createDocumentAuditLogData({
|
// nobody advances until the whole group has completed.
|
||||||
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
|
const hasCompletedCurrentStep = nextRecipients.every((pendingRecipient) =>
|
||||||
envelopeId: envelope.id,
|
isRecipientBefore(recipient, pendingRecipient, sequencing),
|
||||||
user: {
|
);
|
||||||
name: recipientName,
|
|
||||||
email: recipientEmail,
|
if (
|
||||||
},
|
nextRecipients.length > 0 &&
|
||||||
requestMetadata,
|
hasCompletedCurrentStep &&
|
||||||
|
// Ensure that the next recipient can actually act on the document.
|
||||||
|
isRecipientTurnBySigningOrder(pendingRecipients, nextRecipients[0], sequencing)
|
||||||
|
) {
|
||||||
|
// Dictation is only allowed when advancing to a single-recipient step.
|
||||||
|
const canDictateNextSigner =
|
||||||
|
Boolean(dictatedNextSigner) &&
|
||||||
|
Boolean(envelope.documentMeta?.allowDictateNextSigner) &&
|
||||||
|
nextRecipients.length === 1;
|
||||||
|
|
||||||
|
if (canDictateNextSigner && dictatedNextSigner) {
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
const [nextRecipient] = nextRecipients;
|
||||||
|
|
||||||
|
await tx.recipient.update({
|
||||||
|
where: { id: nextRecipient.id },
|
||||||
data: {
|
data: {
|
||||||
recipientEmail: nextRecipient.email,
|
sendStatus: SendStatus.SENT,
|
||||||
recipientName: nextRecipient.name,
|
sentAt: new Date(),
|
||||||
recipientId: nextRecipient.id,
|
name: dictatedNextSigner.name,
|
||||||
recipientRole: nextRecipient.role,
|
email: dictatedNextSigner.email,
|
||||||
changes: [
|
|
||||||
{
|
|
||||||
type: RECIPIENT_DIFF_TYPE.NAME,
|
|
||||||
from: nextRecipient.name,
|
|
||||||
to: nextSigner.name,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: RECIPIENT_DIFF_TYPE.EMAIL,
|
|
||||||
from: nextRecipient.email,
|
|
||||||
to: nextSigner.email,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
}),
|
});
|
||||||
|
|
||||||
|
await tx.documentAuditLog.create({
|
||||||
|
data: createDocumentAuditLogData({
|
||||||
|
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
|
||||||
|
envelopeId: envelope.id,
|
||||||
|
user: {
|
||||||
|
name: recipientName,
|
||||||
|
email: recipientEmail,
|
||||||
|
},
|
||||||
|
requestMetadata,
|
||||||
|
data: {
|
||||||
|
recipientEmail: nextRecipient.email,
|
||||||
|
recipientName: nextRecipient.name,
|
||||||
|
recipientId: nextRecipient.id,
|
||||||
|
recipientRole: nextRecipient.role,
|
||||||
|
changes: [
|
||||||
|
{
|
||||||
|
type: RECIPIENT_DIFF_TYPE.NAME,
|
||||||
|
from: nextRecipient.name,
|
||||||
|
to: dictatedNextSigner.name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: RECIPIENT_DIFF_TYPE.EMAIL,
|
||||||
|
from: nextRecipient.email,
|
||||||
|
to: dictatedNextSigner.email,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await prisma.recipient.updateMany({
|
||||||
|
where: {
|
||||||
|
id: {
|
||||||
|
in: nextRecipients.map((nextRecipient) => nextRecipient.id),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
sendStatus: SendStatus.SENT,
|
||||||
|
sentAt: new Date(),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await tx.recipient.update({
|
await Promise.allSettled(
|
||||||
where: { id: nextRecipient.id },
|
nextRecipients.map((nextRecipient) =>
|
||||||
data: {
|
jobs.triggerJob({
|
||||||
sendStatus: SendStatus.SENT,
|
name: 'send.signing.requested.email',
|
||||||
sentAt: new Date(),
|
payload: {
|
||||||
...(nextSigner && envelope.documentMeta?.allowDictateNextSigner
|
userId: envelope.userId,
|
||||||
? {
|
documentId: legacyDocumentId,
|
||||||
name: nextSigner.name,
|
recipientId: nextRecipient.id,
|
||||||
email: nextSigner.email,
|
requestMetadata,
|
||||||
}
|
},
|
||||||
: {}),
|
}),
|
||||||
},
|
),
|
||||||
});
|
);
|
||||||
});
|
}
|
||||||
|
|
||||||
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 { getRecipientsInActiveSigningStep } 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';
|
||||||
@@ -150,10 +151,9 @@ export const sendDocument = async ({ id, userId, teamId, sendEmail, requestMetad
|
|||||||
let recipientsToNotify = envelope.recipients;
|
let recipientsToNotify = envelope.recipients;
|
||||||
|
|
||||||
if (signingOrder === DocumentSigningOrder.SEQUENTIAL) {
|
if (signingOrder === DocumentSigningOrder.SEQUENTIAL) {
|
||||||
// Get the currently active recipient.
|
recipientsToNotify = getRecipientsInActiveSigningStep(envelope.recipients, {
|
||||||
recipientsToNotify = envelope.recipients
|
strictlySequential: isTspEnvelope(envelope),
|
||||||
.filter((r) => r.signingStatus === SigningStatus.NOT_SIGNED && r.role !== RecipientRole.CC)
|
});
|
||||||
.slice(0, 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (envelope.envelopeItems.length === 0) {
|
if (envelope.envelopeItems.length === 0) {
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ 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 { assignOmittedRecipientSigningOrders } from '../recipient/assign-omitted-recipient-signing-orders';
|
||||||
|
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 { 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';
|
||||||
@@ -280,6 +282,23 @@ 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,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const requestedRecipients = [...(data.recipients ?? []), ...defaultRecipients];
|
||||||
|
|
||||||
|
assertCompatibleRecipientGrouping({ signatureLevel, recipients: requestedRecipients });
|
||||||
|
|
||||||
|
const recipientsToCreate = assignOmittedRecipientSigningOrders({ recipients: requestedRecipients });
|
||||||
|
|
||||||
const visibility = visibilityOverride || settings.documentVisibility;
|
const visibility = visibilityOverride || settings.documentVisibility;
|
||||||
|
|
||||||
const emailId = meta?.emailId;
|
const emailId = meta?.emailId;
|
||||||
@@ -403,21 +422,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) => {
|
recipientsToCreate.map(async (recipient) => {
|
||||||
const recipientAuthOptions = createRecipientAuthOptions({
|
const recipientAuthOptions = createRecipientAuthOptions({
|
||||||
accessAuth: recipient.accessAuth ?? [],
|
accessAuth: recipient.accessAuth ?? [],
|
||||||
actionAuth: recipient.actionAuth ?? [],
|
actionAuth: recipient.actionAuth ?? [],
|
||||||
|
|||||||
@@ -5,13 +5,15 @@ 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 { isTspEnvelope } from '../../types/signature-level';
|
||||||
|
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 +262,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, { strictlySequential: isTspEnvelope(envelope) });
|
||||||
|
|
||||||
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/recipient-queries';
|
||||||
|
|
||||||
export type GetFieldsForTokenOptions = {
|
export type GetFieldsForTokenOptions = {
|
||||||
token: string;
|
token: string;
|
||||||
};
|
};
|
||||||
@@ -31,10 +33,8 @@ 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,
|
||||||
|
AND: [getLaterSigningStepRecipientsWhereInput(recipient)],
|
||||||
},
|
},
|
||||||
envelope: {
|
envelope: {
|
||||||
id: recipient.envelopeId,
|
id: recipient.envelopeId,
|
||||||
|
|||||||
@@ -1,6 +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 { getRecipientFieldsWhereInput } from '@documenso/lib/utils/recipient-queries';
|
||||||
import { assertRecipientNotExpired } from '@documenso/lib/utils/recipients';
|
import { assertRecipientNotExpired } 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 +26,10 @@ export const removeSignedFieldWithToken = async ({
|
|||||||
const field = await prisma.field.findFirstOrThrow({
|
const field = await prisma.field.findFirstOrThrow({
|
||||||
where: {
|
where: {
|
||||||
id: fieldId,
|
id: fieldId,
|
||||||
recipient: {
|
recipient: getRecipientFieldsWhereInput({
|
||||||
...(recipient.role !== RecipientRole.ASSISTANT
|
recipient,
|
||||||
? {
|
allowAssistantAccessToOtherRecipients: true,
|
||||||
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,6 +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 { getRecipientFieldsWhereInput } from '../../utils/recipient-queries';
|
||||||
import { assertRecipientNotExpired } from '../../utils/recipients';
|
import { assertRecipientNotExpired } from '../../utils/recipients';
|
||||||
import { validateFieldAuth } from '../document/validate-field-auth';
|
import { validateFieldAuth } from '../document/validate-field-auth';
|
||||||
|
|
||||||
@@ -65,21 +67,10 @@ export const signFieldWithToken = async ({
|
|||||||
const field = await prisma.field.findFirstOrThrow({
|
const field = await prisma.field.findFirstOrThrow({
|
||||||
where: {
|
where: {
|
||||||
id: fieldId,
|
id: fieldId,
|
||||||
recipient: {
|
recipient: getRecipientFieldsWhereInput({
|
||||||
...(recipient.role !== RecipientRole.ASSISTANT
|
recipient,
|
||||||
? {
|
allowAssistantAccessToOtherRecipients: true,
|
||||||
id: recipient.id,
|
}),
|
||||||
}
|
|
||||||
: {
|
|
||||||
signingStatus: {
|
|
||||||
not: SigningStatus.SIGNED,
|
|
||||||
},
|
|
||||||
signingOrder: {
|
|
||||||
gte: recipient.signingOrder ?? 0,
|
|
||||||
},
|
|
||||||
envelopeId: recipient.envelopeId,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
envelope: {
|
envelope: {
|
||||||
@@ -124,6 +115,16 @@ export const signFieldWithToken = async ({
|
|||||||
throw new Error(`Field ${fieldId} has no recipientId`);
|
throw new Error(`Field ${fieldId} has no recipientId`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { RecipientRole } from '@prisma/client';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
assignOmittedRecipientSigningOrders,
|
||||||
|
resolveReplacedRecipientSigningOrders,
|
||||||
|
} from './assign-omitted-recipient-signing-orders';
|
||||||
|
|
||||||
|
const signer = (signingOrder?: number | null, id?: number) => ({
|
||||||
|
id,
|
||||||
|
role: RecipientRole.SIGNER,
|
||||||
|
signingOrder,
|
||||||
|
});
|
||||||
|
|
||||||
|
const cc = () => ({ role: RecipientRole.CC, signingOrder: undefined });
|
||||||
|
|
||||||
|
const ordersOf = (recipients: Array<{ signingOrder?: number | null }>) =>
|
||||||
|
recipients.map((recipient) => recipient.signingOrder);
|
||||||
|
|
||||||
|
describe('assignOmittedRecipientSigningOrders', () => {
|
||||||
|
it('numbers omitted orders after the highest explicit order, in request order', () => {
|
||||||
|
const result = assignOmittedRecipientSigningOrders({
|
||||||
|
recipients: [signer(), signer(4), signer(), signer(1)],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(ordersOf(result)).toEqual([5, 4, 6, 1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('continues after the highest order already on the envelope and never numbers CC recipients', () => {
|
||||||
|
const result = assignOmittedRecipientSigningOrders({
|
||||||
|
recipients: [cc(), signer(), signer()],
|
||||||
|
existingRecipients: [signer(2), signer(7), cc()],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(ordersOf(result)).toEqual([undefined, 8, 9]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves additions unordered when a signing recipient on the envelope has no order', () => {
|
||||||
|
const result = assignOmittedRecipientSigningOrders({
|
||||||
|
recipients: [signer(), signer(9)],
|
||||||
|
existingRecipients: [signer(1), signer(null)],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(ordersOf(result)).toEqual([undefined, 9]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveReplacedRecipientSigningOrders', () => {
|
||||||
|
const existingRecipients = [signer(1, 10), signer(2, 11)] as Array<{
|
||||||
|
id: number;
|
||||||
|
role: RecipientRole;
|
||||||
|
signingOrder: number | null;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
it('keeps the persisted order of a recipient whose order was omitted', () => {
|
||||||
|
const { recipients, requestedOrderRecipients } = resolveReplacedRecipientSigningOrders({
|
||||||
|
recipients: [signer(undefined, 11), signer(undefined, 10)],
|
||||||
|
existingRecipients,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(ordersOf(recipients)).toEqual([2, 1]);
|
||||||
|
expect(requestedOrderRecipients).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('numbers new recipients after the kept ones and reports explicitly requested orders', () => {
|
||||||
|
const { recipients, requestedOrderRecipients } = resolveReplacedRecipientSigningOrders({
|
||||||
|
recipients: [signer(undefined, 10), signer(), signer(5, 11), signer()],
|
||||||
|
existingRecipients,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(ordersOf(recipients)).toEqual([1, 6, 5, 7]);
|
||||||
|
expect(requestedOrderRecipients).toEqual([recipients[2]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not report an unchanged persisted order as requested', () => {
|
||||||
|
const { requestedOrderRecipients } = resolveReplacedRecipientSigningOrders({
|
||||||
|
recipients: [signer(1, 10), signer(3)],
|
||||||
|
existingRecipients,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(ordersOf(requestedOrderRecipients)).toEqual([3]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import type { Recipient } from '@prisma/client';
|
||||||
|
|
||||||
|
import { hasSigningOrder, isCcRecipient } from '../../utils/recipients';
|
||||||
|
|
||||||
|
type OrderableRecipient = Pick<Recipient, 'role'> & { signingOrder?: number | null };
|
||||||
|
|
||||||
|
type AssignOmittedRecipientSigningOrdersOptions<T> = {
|
||||||
|
recipients: T[];
|
||||||
|
existingRecipients?: OrderableRecipient[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Numbers signing recipients created without an order after the highest
|
||||||
|
* explicit order on the envelope, in request order.
|
||||||
|
*/
|
||||||
|
export const assignOmittedRecipientSigningOrders = <T extends OrderableRecipient>({
|
||||||
|
recipients,
|
||||||
|
existingRecipients = [],
|
||||||
|
}: AssignOmittedRecipientSigningOrdersOptions<T>): T[] => {
|
||||||
|
// Numbers sort ahead of unordered rows, so numbering an addition would move
|
||||||
|
// it in front of a legacy unordered signer. Left unordered it queues behind
|
||||||
|
// that tail by id.
|
||||||
|
const hasUnorderedExistingSigner = existingRecipients.some(
|
||||||
|
(recipient) => !isCcRecipient(recipient) && !hasSigningOrder(recipient),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasUnorderedExistingSigner) {
|
||||||
|
return recipients;
|
||||||
|
}
|
||||||
|
|
||||||
|
let highestOrder = 0;
|
||||||
|
|
||||||
|
for (const recipient of [...existingRecipients, ...recipients]) {
|
||||||
|
if (hasSigningOrder(recipient)) {
|
||||||
|
highestOrder = Math.max(highestOrder, recipient.signingOrder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextOrder = highestOrder + 1;
|
||||||
|
|
||||||
|
return recipients.map((recipient) => {
|
||||||
|
if (isCcRecipient(recipient) || hasSigningOrder(recipient)) {
|
||||||
|
return recipient;
|
||||||
|
}
|
||||||
|
|
||||||
|
const signingOrder = nextOrder;
|
||||||
|
|
||||||
|
nextOrder += 1;
|
||||||
|
|
||||||
|
return { ...recipient, signingOrder };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
type ReplacementRecipient = OrderableRecipient & { id?: number | null };
|
||||||
|
|
||||||
|
type ResolveReplacedRecipientSigningOrdersOptions<T> = {
|
||||||
|
recipients: T[];
|
||||||
|
existingRecipients: Array<Pick<Recipient, 'id' | 'role' | 'signingOrder'>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves signing orders for a full recipient replacement (`set*Recipients`):
|
||||||
|
* a persisted recipient whose order was omitted keeps it, new recipients are
|
||||||
|
* numbered after the kept ones.
|
||||||
|
*
|
||||||
|
* `requestedOrderRecipients` is the subset whose order differs from what was
|
||||||
|
* persisted — the only entries that can form a new signing group.
|
||||||
|
*/
|
||||||
|
export const resolveReplacedRecipientSigningOrders = <T extends ReplacementRecipient>({
|
||||||
|
recipients,
|
||||||
|
existingRecipients,
|
||||||
|
}: ResolveReplacedRecipientSigningOrdersOptions<T>): { recipients: T[]; requestedOrderRecipients: T[] } => {
|
||||||
|
const persistedById = new Map(existingRecipients.map((recipient) => [recipient.id, recipient]));
|
||||||
|
|
||||||
|
const findPersisted = (recipient: T) =>
|
||||||
|
typeof recipient.id === 'number' ? persistedById.get(recipient.id) : undefined;
|
||||||
|
|
||||||
|
const preserved = recipients.map((recipient) => {
|
||||||
|
const persisted = findPersisted(recipient);
|
||||||
|
|
||||||
|
if (!persisted || hasSigningOrder(recipient)) {
|
||||||
|
return recipient;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...recipient, signingOrder: persisted.signingOrder };
|
||||||
|
});
|
||||||
|
|
||||||
|
const keptRecipients = preserved.filter((recipient) => findPersisted(recipient) !== undefined);
|
||||||
|
const newRecipients = preserved.filter((recipient) => findPersisted(recipient) === undefined);
|
||||||
|
|
||||||
|
const numberedNewRecipients = assignOmittedRecipientSigningOrders({
|
||||||
|
recipients: newRecipients,
|
||||||
|
existingRecipients: keptRecipients,
|
||||||
|
});
|
||||||
|
|
||||||
|
let numberedIndex = 0;
|
||||||
|
|
||||||
|
const resolved = preserved.map((recipient) => {
|
||||||
|
if (findPersisted(recipient) !== undefined) {
|
||||||
|
return recipient;
|
||||||
|
}
|
||||||
|
|
||||||
|
const numbered = numberedNewRecipients[numberedIndex];
|
||||||
|
|
||||||
|
numberedIndex += 1;
|
||||||
|
|
||||||
|
return numbered;
|
||||||
|
});
|
||||||
|
|
||||||
|
const requestedOrderRecipients = resolved.filter((_recipient, index) => {
|
||||||
|
const requested = recipients[index];
|
||||||
|
const persisted = findPersisted(requested);
|
||||||
|
|
||||||
|
return hasSigningOrder(requested) && (!persisted || persisted.signingOrder !== requested.signingOrder);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { recipients: resolved, requestedOrderRecipients };
|
||||||
|
};
|
||||||
@@ -12,7 +12,9 @@ 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';
|
||||||
|
import { assignOmittedRecipientSigningOrders } from './assign-omitted-recipient-signing-orders';
|
||||||
|
|
||||||
export interface CreateEnvelopeRecipientsOptions {
|
export interface CreateEnvelopeRecipientsOptions {
|
||||||
userId: number;
|
userId: number;
|
||||||
@@ -46,7 +48,6 @@ export const createEnvelopeRecipients = async ({
|
|||||||
const envelope = await prisma.envelope.findFirst({
|
const envelope = await prisma.envelope.findFirst({
|
||||||
where: envelopeWhereInput,
|
where: envelopeWhereInput,
|
||||||
include: {
|
include: {
|
||||||
recipients: true,
|
|
||||||
team: {
|
team: {
|
||||||
select: {
|
select: {
|
||||||
organisation: {
|
organisation: {
|
||||||
@@ -91,14 +92,32 @@ export const createEnvelopeRecipients = async ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedRecipients = recipientsToCreate.map((recipient) => ({
|
|
||||||
...recipient,
|
|
||||||
email: recipient.email.toLowerCase(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const createdRecipients = await prisma.$transaction(async (tx) => {
|
const createdRecipients = await prisma.$transaction(async (tx) => {
|
||||||
|
// Lock the envelope so concurrent additions allocate distinct signing orders.
|
||||||
|
await tx.$queryRaw`SELECT "id" FROM "Envelope" WHERE "id" = ${envelope.id} FOR UPDATE`;
|
||||||
|
|
||||||
await assertEnvelopeMutable(envelope, tx);
|
await assertEnvelopeMutable(envelope, tx);
|
||||||
|
|
||||||
|
const existingRecipients = await tx.recipient.findMany({
|
||||||
|
where: {
|
||||||
|
envelopeId: envelope.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assertCompatibleRecipientGrouping({
|
||||||
|
signatureLevel: envelope.signatureLevel,
|
||||||
|
recipients: recipientsToCreate,
|
||||||
|
existingRecipients,
|
||||||
|
});
|
||||||
|
|
||||||
|
const normalizedRecipients = assignOmittedRecipientSigningOrders({
|
||||||
|
recipients: recipientsToCreate.map((recipient) => ({
|
||||||
|
...recipient,
|
||||||
|
email: recipient.email.toLowerCase(),
|
||||||
|
})),
|
||||||
|
existingRecipients,
|
||||||
|
});
|
||||||
|
|
||||||
return await Promise.all(
|
return await Promise.all(
|
||||||
normalizedRecipients.map(async (recipient) => {
|
normalizedRecipients.map(async (recipient) => {
|
||||||
const authOptions = createRecipientAuthOptions({
|
const authOptions = createRecipientAuthOptions({
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
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 { isTspEnvelope } from '../../types/signature-level';
|
||||||
|
import { isRecipientTurnBySigningOrder } from '../../utils/recipient-groups';
|
||||||
|
|
||||||
export type GetIsRecipientTurnOptions = {
|
export type GetIsRecipientTurnOptions = {
|
||||||
token: string;
|
token: string;
|
||||||
@@ -17,11 +20,7 @@ export async function getIsRecipientsTurnToSign({ token }: GetIsRecipientTurnOpt
|
|||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
documentMeta: true,
|
documentMeta: true,
|
||||||
recipients: {
|
recipients: true,
|
||||||
orderBy: {
|
|
||||||
signingOrder: 'asc',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -29,24 +28,13 @@ 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.
|
strictlySequential: isTspEnvelope(envelope),
|
||||||
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/recipient-queries';
|
||||||
|
|
||||||
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,7 +17,9 @@ 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';
|
||||||
|
import { resolveReplacedRecipientSigningOrders } from './assign-omitted-recipient-signing-orders';
|
||||||
|
|
||||||
export interface SetDocumentRecipientsOptions {
|
export interface SetDocumentRecipientsOptions {
|
||||||
userId: number;
|
userId: number;
|
||||||
@@ -98,13 +100,22 @@ export const setDocumentRecipients = async ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedRecipients = recipients.map((recipient) => ({
|
|
||||||
...recipient,
|
|
||||||
email: recipient.email.toLowerCase(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const existingRecipients = envelope.recipients;
|
const existingRecipients = envelope.recipients;
|
||||||
|
|
||||||
|
const { recipients: normalizedRecipients, requestedOrderRecipients } = resolveReplacedRecipientSigningOrders({
|
||||||
|
recipients: recipients.map((recipient) => ({
|
||||||
|
...recipient,
|
||||||
|
email: recipient.email.toLowerCase(),
|
||||||
|
})),
|
||||||
|
existingRecipients,
|
||||||
|
});
|
||||||
|
|
||||||
|
assertCompatibleRecipientGrouping({
|
||||||
|
signatureLevel: envelope.signatureLevel,
|
||||||
|
recipients: requestedOrderRecipients,
|
||||||
|
existingRecipients: normalizedRecipients.filter((recipient) => !requestedOrderRecipients.includes(recipient)),
|
||||||
|
});
|
||||||
|
|
||||||
const removedRecipients = existingRecipients.filter(
|
const removedRecipients = existingRecipients.filter(
|
||||||
(existingRecipient) => !normalizedRecipients.find((recipient) => recipient.id === existingRecipient.id),
|
(existingRecipient) => !normalizedRecipients.find((recipient) => recipient.id === existingRecipient.id),
|
||||||
);
|
);
|
||||||
@@ -342,7 +353,9 @@ const hasRecipientBeenChanged = (recipient: Recipient, newRecipientData: Recipie
|
|||||||
recipient.email !== newRecipientData.email ||
|
recipient.email !== newRecipientData.email ||
|
||||||
recipient.name !== newRecipientData.name ||
|
recipient.name !== newRecipientData.name ||
|
||||||
recipient.role !== newRecipientData.role ||
|
recipient.role !== newRecipientData.role ||
|
||||||
recipient.signingOrder !== newRecipientData.signingOrder ||
|
// Null and undefined both mean "no order": the request schema cannot
|
||||||
|
// carry null, so a persisted null arrives as undefined
|
||||||
|
(recipient.signingOrder ?? null) !== (newRecipientData.signingOrder ?? null) ||
|
||||||
!isDeepEqual(authOptions.accessAuth, newRecipientAccessAuth) ||
|
!isDeepEqual(authOptions.accessAuth, newRecipientAccessAuth) ||
|
||||||
!isDeepEqual(authOptions.actionAuth, newRecipientActionAuth)
|
!isDeepEqual(authOptions.actionAuth, newRecipientActionAuth)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ 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';
|
||||||
|
import { resolveReplacedRecipientSigningOrders } from './assign-omitted-recipient-signing-orders';
|
||||||
|
|
||||||
export type SetTemplateRecipientsOptions = {
|
export type SetTemplateRecipientsOptions = {
|
||||||
userId: number;
|
userId: number;
|
||||||
@@ -68,23 +70,32 @@ export const setTemplateRecipients = async ({ userId, teamId, id, recipients }:
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedRecipients = recipients.map((recipient) => {
|
const existingRecipients = envelope.recipients;
|
||||||
// Force replace any changes to the name or email of the direct recipient.
|
|
||||||
if (envelope.directLink && recipient.id === envelope.directLink.directTemplateRecipientId) {
|
const { recipients: normalizedRecipients, requestedOrderRecipients } = resolveReplacedRecipientSigningOrders({
|
||||||
|
recipients: recipients.map((recipient) => {
|
||||||
|
// Force replace any changes to the name or email of the direct recipient.
|
||||||
|
if (envelope.directLink && recipient.id === envelope.directLink.directTemplateRecipientId) {
|
||||||
|
return {
|
||||||
|
...recipient,
|
||||||
|
email: DIRECT_TEMPLATE_RECIPIENT_EMAIL,
|
||||||
|
name: DIRECT_TEMPLATE_RECIPIENT_NAME,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...recipient,
|
...recipient,
|
||||||
email: DIRECT_TEMPLATE_RECIPIENT_EMAIL,
|
email: recipient.email.toLowerCase(),
|
||||||
name: DIRECT_TEMPLATE_RECIPIENT_NAME,
|
|
||||||
};
|
};
|
||||||
}
|
}),
|
||||||
|
existingRecipients,
|
||||||
return {
|
|
||||||
...recipient,
|
|
||||||
email: recipient.email.toLowerCase(),
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const existingRecipients = envelope.recipients;
|
assertCompatibleRecipientGrouping({
|
||||||
|
signatureLevel: envelope.signatureLevel,
|
||||||
|
recipients: requestedOrderRecipients,
|
||||||
|
existingRecipients: normalizedRecipients.filter((recipient) => !requestedOrderRecipients.includes(recipient)),
|
||||||
|
});
|
||||||
|
|
||||||
const removedRecipients = existingRecipients.filter(
|
const removedRecipients = existingRecipients.filter(
|
||||||
(existingRecipient) => !normalizedRecipients.find((recipient) => recipient.id === existingRecipient.id),
|
(existingRecipient) => !normalizedRecipients.find((recipient) => recipient.id === existingRecipient.id),
|
||||||
|
|||||||
@@ -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,24 @@ export const updateEnvelopeRecipients = async ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const orderUpdates = recipients.flatMap((update) => {
|
||||||
|
const existingRecipient = envelope.recipients.find((recipient) => recipient.id === update.id);
|
||||||
|
|
||||||
|
if (!existingRecipient || update.signingOrder === undefined) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [{ ...existingRecipient, ...update }];
|
||||||
|
});
|
||||||
|
|
||||||
|
const orderUpdateIds = new Set(orderUpdates.map((recipient) => recipient.id));
|
||||||
|
|
||||||
|
assertCompatibleRecipientGrouping({
|
||||||
|
signatureLevel: envelope.signatureLevel,
|
||||||
|
recipients: orderUpdates,
|
||||||
|
existingRecipients: envelope.recipients.filter((recipient) => !orderUpdateIds.has(recipient.id)),
|
||||||
|
});
|
||||||
|
|
||||||
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,65 @@
|
|||||||
|
import { RecipientRole } from '@prisma/client';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { SignatureLevel } from '../../types/signature-level';
|
||||||
|
import { assertCompatibleRecipientGrouping } from './assert-compatible-recipient-grouping';
|
||||||
|
|
||||||
|
type TestRecipient = { role: RecipientRole; signingOrder?: number | null };
|
||||||
|
|
||||||
|
const signer = (signingOrder?: number | null): TestRecipient => ({ role: RecipientRole.SIGNER, signingOrder });
|
||||||
|
const cc = (signingOrder?: number | null): TestRecipient => ({ role: RecipientRole.CC, signingOrder });
|
||||||
|
|
||||||
|
const expectRejected = (
|
||||||
|
signatureLevel: string,
|
||||||
|
recipients: TestRecipient[],
|
||||||
|
existingRecipients: TestRecipient[] = [],
|
||||||
|
) => {
|
||||||
|
expect(() => assertCompatibleRecipientGrouping({ signatureLevel, recipients, existingRecipients })).toThrow(
|
||||||
|
/signing group|same signing step/i,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const expectAccepted = (
|
||||||
|
signatureLevel: string,
|
||||||
|
recipients: TestRecipient[],
|
||||||
|
existingRecipients: TestRecipient[] = [],
|
||||||
|
) => {
|
||||||
|
expect(() => assertCompatibleRecipientGrouping({ signatureLevel, recipients, existingRecipients })).not.toThrow();
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('assertCompatibleRecipientGrouping', () => {
|
||||||
|
describe('AES/QES envelopes', () => {
|
||||||
|
for (const signatureLevel of [SignatureLevel.AES, SignatureLevel.QES]) {
|
||||||
|
it(`rejects two signers sharing an explicit 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)]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('rejects a new signer joining a step that already exists on the envelope', () => {
|
||||||
|
expectRejected(SignatureLevel.AES, [signer(1)], [signer(1)]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts any number of signers without a signing order', () => {
|
||||||
|
expectAccepted(SignatureLevel.AES, [signer(), signer(null), signer(undefined)]);
|
||||||
|
expectAccepted(SignatureLevel.AES, [signer(1), signer(), signer()], [signer(null), signer(null)]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not validate untouched existing recipients against each other', () => {
|
||||||
|
expectAccepted(SignatureLevel.AES, [signer(3)], [signer(1), signer(1)]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores CC recipients', () => {
|
||||||
|
expectAccepted(SignatureLevel.AES, [signer(1), cc(1), cc(1)], [cc(1)]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SES envelopes', () => {
|
||||||
|
it('permits signing groups', () => {
|
||||||
|
expectAccepted(SignatureLevel.SES, [signer(1), signer(2), signer(2)], [signer(2)]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import type { Recipient } from '@prisma/client';
|
||||||
|
|
||||||
|
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||||
|
import { isTspEnvelope } from '../../types/signature-level';
|
||||||
|
import { hasSigningOrder, isCcRecipient } from '../../utils/recipients';
|
||||||
|
|
||||||
|
type GroupableRecipient = Pick<Recipient, 'role'> & { signingOrder?: number | null };
|
||||||
|
|
||||||
|
type AssertCompatibleRecipientGroupingOptions = {
|
||||||
|
signatureLevel: string;
|
||||||
|
recipients: GroupableRecipient[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recipients this request leaves untouched. A request may not join their
|
||||||
|
* steps, but they are never validated against each other: legacy duplicates
|
||||||
|
* are sequenced strictly at runtime instead.
|
||||||
|
*/
|
||||||
|
existingRecipients?: GroupableRecipient[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reject newly requested signing groups on AES/QES envelopes: group members
|
||||||
|
* may sign at the same time, and a TSP signature computed over a document
|
||||||
|
* snapshot would be invalidated by an overlapping signer.
|
||||||
|
*
|
||||||
|
* An omitted order never forms a group (it is numbered on creation, or
|
||||||
|
* sequenced by id for legacy rows). CC recipients never sign and are ignored.
|
||||||
|
*/
|
||||||
|
export const assertCompatibleRecipientGrouping = ({
|
||||||
|
signatureLevel,
|
||||||
|
recipients,
|
||||||
|
existingRecipients = [],
|
||||||
|
}: AssertCompatibleRecipientGroupingOptions): void => {
|
||||||
|
if (!isTspEnvelope({ signatureLevel })) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const takenOrders = new Set<number>();
|
||||||
|
|
||||||
|
for (const recipient of existingRecipients) {
|
||||||
|
if (!isCcRecipient(recipient) && hasSigningOrder(recipient)) {
|
||||||
|
takenOrders.add(recipient.signingOrder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const recipient of recipients) {
|
||||||
|
if (isCcRecipient(recipient) || !hasSigningOrder(recipient)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (takenOrders.has(recipient.signingOrder)) {
|
||||||
|
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 or omit it.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
takenOrders.add(recipient.signingOrder);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -40,11 +40,17 @@ import {
|
|||||||
extractDocumentAuthMethods,
|
extractDocumentAuthMethods,
|
||||||
} from '../../utils/document-auth';
|
} from '../../utils/document-auth';
|
||||||
import { mapSecondaryIdToTemplateId } from '../../utils/envelope';
|
import { mapSecondaryIdToTemplateId } from '../../utils/envelope';
|
||||||
import { getRecipientsWithMissingFields } from '../../utils/recipients';
|
import { getRecipientsInActiveSigningStep } from '../../utils/recipient-groups';
|
||||||
|
import {
|
||||||
|
getRecipientsWithMissingFields,
|
||||||
|
isRecipientBefore,
|
||||||
|
sortRecipientsBySigningPosition,
|
||||||
|
} 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';
|
||||||
import { incrementDocumentId } from '../envelope/increment-id';
|
import { incrementDocumentId } 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 { assignOmittedRecipientSigningOrders } from '../recipient/assign-omitted-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 { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||||
@@ -211,6 +217,16 @@ export const createDocumentFromDirectTemplate = async ({
|
|||||||
(recipient) => recipient.id !== directTemplateRecipient.id,
|
(recipient) => recipient.id !== directTemplateRecipient.id,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Number unordered recipients by template position since the copies get new ids.
|
||||||
|
const signingOrderByTemplateRecipientId = new Map(
|
||||||
|
assignOmittedRecipientSigningOrders({
|
||||||
|
recipients: sortRecipientsBySigningPosition(recipients),
|
||||||
|
}).map((recipient) => [recipient.id, recipient.signingOrder]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const resolveSigningOrder = (templateRecipientId: number) =>
|
||||||
|
signingOrderByTemplateRecipientId.get(templateRecipientId) ?? null;
|
||||||
|
|
||||||
// Carry the template's level forward, coercing if the instance mode has
|
// Carry the template's level forward, coercing if the instance mode has
|
||||||
// changed since the template was created. ZSignatureLevelSchema parses the
|
// changed since the template was created. ZSignatureLevelSchema parses the
|
||||||
// free-form TEXT column defensively. Resolved before meta extraction so
|
// free-form TEXT column defensively. Resolved before meta extraction so
|
||||||
@@ -410,7 +426,7 @@ export const createDocumentFromDirectTemplate = async ({
|
|||||||
}),
|
}),
|
||||||
sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT,
|
sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT,
|
||||||
signingStatus: recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
|
signingStatus: recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
|
||||||
signingOrder: recipient.signingOrder,
|
signingOrder: resolveSigningOrder(recipient.id),
|
||||||
token: nanoid(),
|
token: nanoid(),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
@@ -482,7 +498,7 @@ export const createDocumentFromDirectTemplate = async ({
|
|||||||
signingStatus: SigningStatus.SIGNED,
|
signingStatus: SigningStatus.SIGNED,
|
||||||
sendStatus: SendStatus.SENT,
|
sendStatus: SendStatus.SENT,
|
||||||
signedAt: initialRequestTime,
|
signedAt: initialRequestTime,
|
||||||
signingOrder: directTemplateRecipient.signingOrder,
|
signingOrder: resolveSigningOrder(directTemplateRecipient.id),
|
||||||
fields: {
|
fields: {
|
||||||
createMany: {
|
createMany: {
|
||||||
data: directTemplateNonSignatureFields.map(({ templateField, customText }) => {
|
data: directTemplateNonSignatureFields.map(({ templateField, customText }) => {
|
||||||
@@ -676,6 +692,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 +711,20 @@ 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 nextRecipients = getRecipientsInActiveSigningStep(pendingRecipients);
|
||||||
|
|
||||||
if (nextRecipient) {
|
// The direct recipient can share a step with other recipients (a signing
|
||||||
|
// group). Those peers are still pending, so without this check they would
|
||||||
|
// look like the "next" step and be dictated over — dictation may only
|
||||||
|
// affect a strictly later step.
|
||||||
|
const hasCompletedCurrentStep = nextRecipients.every((pendingRecipient) =>
|
||||||
|
isRecipientBefore(createdDirectRecipient, pendingRecipient),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Dictation can only apply when the next step is a single recipient.
|
||||||
|
const nextRecipient = hasCompletedCurrentStep && nextRecipients.length === 1 ? nextRecipients[0] : null;
|
||||||
|
|
||||||
|
if (nextRecipient && documentMeta.allowDictateNextSigner) {
|
||||||
auditLogsToCreate.push(
|
auditLogsToCreate.push(
|
||||||
createDocumentAuditLogData({
|
createDocumentAuditLogData({
|
||||||
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
|
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
|
||||||
@@ -730,12 +758,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,
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { nanoid, prefixedId } from '@documenso/lib/universal/id';
|
import { nanoid, prefixedId } from '@documenso/lib/universal/id';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import type { DocumentDistributionMethod, DocumentSigningOrder } from '@prisma/client';
|
import type { DocumentDistributionMethod, DocumentSigningOrder, Prisma } from '@prisma/client';
|
||||||
import {
|
import {
|
||||||
DocumentSource,
|
DocumentSource,
|
||||||
EnvelopeType,
|
EnvelopeType,
|
||||||
@@ -52,6 +52,9 @@ import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
|||||||
import { incrementDocumentId } from '../envelope/increment-id';
|
import { incrementDocumentId } from '../envelope/increment-id';
|
||||||
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
|
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
|
||||||
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
|
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
|
||||||
|
import { assignOmittedRecipientSigningOrders } from '../recipient/assign-omitted-recipient-signing-orders';
|
||||||
|
import { assertCompatibleRecipientGrouping } from '../signature-level/assert-compatible-recipient-grouping';
|
||||||
|
import { assertCompatibleRecipientRole } from '../signature-level/assert-compatible-recipient-role';
|
||||||
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 { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||||
@@ -309,6 +312,11 @@ export const createDocumentFromTemplate = async ({
|
|||||||
include: {
|
include: {
|
||||||
fields: true,
|
fields: true,
|
||||||
},
|
},
|
||||||
|
// Unordered template recipients are numbered in this sequence.
|
||||||
|
orderBy: [
|
||||||
|
{ signingOrder: { sort: 'asc', nulls: 'last' } },
|
||||||
|
{ id: 'asc' },
|
||||||
|
] satisfies Prisma.RecipientOrderByWithRelationInput[],
|
||||||
},
|
},
|
||||||
envelopeItems: {
|
envelopeItems: {
|
||||||
include: {
|
include: {
|
||||||
@@ -430,8 +438,6 @@ export const createDocumentFromTemplate = async ({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const allFinalRecipients = [...finalRecipients, ...defaultRecipientsFinal];
|
|
||||||
|
|
||||||
// Key = original envelope item ID
|
// Key = original envelope item ID
|
||||||
// Value = duplicated envelope item ID.
|
// Value = duplicated envelope item ID.
|
||||||
const oldEnvelopeItemToNewEnvelopeItemIdMap: Record<string, string> = {};
|
const oldEnvelopeItemToNewEnvelopeItemIdMap: Record<string, string> = {};
|
||||||
@@ -524,6 +530,26 @@ export const createDocumentFromTemplate = async ({
|
|||||||
strict: false,
|
strict: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const requestedOrderRecipients = finalRecipients.filter((finalRecipient) => {
|
||||||
|
const override = recipients.find((recipient) => recipient.id === finalRecipient.templateRecipientId);
|
||||||
|
|
||||||
|
return typeof override?.signingOrder === 'number';
|
||||||
|
});
|
||||||
|
|
||||||
|
assertCompatibleRecipientGrouping({
|
||||||
|
signatureLevel,
|
||||||
|
recipients: requestedOrderRecipients,
|
||||||
|
existingRecipients: finalRecipients.filter((recipient) => !requestedOrderRecipients.includes(recipient)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const allFinalRecipients = assignOmittedRecipientSigningOrders({
|
||||||
|
recipients: [...finalRecipients, ...defaultRecipientsFinal],
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const recipient of allFinalRecipients) {
|
||||||
|
assertCompatibleRecipientRole({ signatureLevel, role: recipient.role });
|
||||||
|
}
|
||||||
|
|
||||||
const documentMeta = await prisma.documentMeta.create({
|
const documentMeta = await prisma.documentMeta.create({
|
||||||
data: extractDerivedDocumentMeta(
|
data: extractDerivedDocumentMeta(
|
||||||
settings,
|
settings,
|
||||||
|
|||||||
@@ -310,7 +310,7 @@ msgstr "{0}-Feld"
|
|||||||
#. placeholder {1}: envelope.title
|
#. placeholder {1}: envelope.title
|
||||||
#: packages/lib/jobs/definitions/emails/send-signing-email.handler.ts
|
#: packages/lib/jobs/definitions/emails/send-signing-email.handler.ts
|
||||||
msgid "{0} has invited you to {recipientActionVerb} the document \"{1}\"."
|
msgid "{0} has invited you to {recipientActionVerb} the document \"{1}\"."
|
||||||
msgstr "{0} hat Sie eingeladen, das Dokument \"{1}\" {recipientActionVerb}."
|
msgstr "{0} hat Sie eingeladen, das Dokument \"{1}\" zu {recipientActionVerb}."
|
||||||
|
|
||||||
#. placeholder {0}: organisation.name
|
#. placeholder {0}: organisation.name
|
||||||
#: apps/remix/app/components/general/settings-upsell/branding-upsell.tsx
|
#: apps/remix/app/components/general/settings-upsell/branding-upsell.tsx
|
||||||
@@ -321,7 +321,7 @@ msgstr ""
|
|||||||
#. placeholder {0}: team.name
|
#. placeholder {0}: team.name
|
||||||
#: packages/lib/jobs/definitions/emails/send-signing-email.handler.ts
|
#: packages/lib/jobs/definitions/emails/send-signing-email.handler.ts
|
||||||
msgid "{0} invited you to {recipientActionVerb} a document"
|
msgid "{0} invited you to {recipientActionVerb} a document"
|
||||||
msgstr "{0} hat dich eingeladen, ein Dokument {recipientActionVerb}"
|
msgstr "{0} hat dich eingeladen, ein Dokument zu {recipientActionVerb}"
|
||||||
|
|
||||||
#. placeholder {0}: remaining.documents
|
#. placeholder {0}: remaining.documents
|
||||||
#. placeholder {1}: quota.documents
|
#. placeholder {1}: quota.documents
|
||||||
@@ -336,7 +336,7 @@ msgstr "{0} von {1} Dokumenten verbleibend in diesem Monat."
|
|||||||
#. placeholder {2}: envelope.title
|
#. placeholder {2}: envelope.title
|
||||||
#: packages/lib/server-only/document/resend-document.ts
|
#: packages/lib/server-only/document/resend-document.ts
|
||||||
msgid "{0} on behalf of \"{1}\" has invited you to {recipientActionVerb} the document \"{2}\"."
|
msgid "{0} on behalf of \"{1}\" has invited you to {recipientActionVerb} the document \"{2}\"."
|
||||||
msgstr "{0} im Namen von \"{1}\" hat Sie eingeladen, das Dokument \"{2}\" {recipientActionVerb}."
|
msgstr "{0} im Namen von \"{1}\" hat Sie eingeladen, das Dokument \"{2}\" zu {recipientActionVerb}."
|
||||||
|
|
||||||
#. placeholder {0}: planClaim.name
|
#. placeholder {0}: planClaim.name
|
||||||
#: apps/remix/app/components/general/settings-org-switcher.tsx
|
#: apps/remix/app/components/general/settings-org-switcher.tsx
|
||||||
@@ -407,15 +407,15 @@ msgstr "{inviterName} hat das Dokument<0/>\"{documentName}\" storniert"
|
|||||||
#. placeholder {0}: _(actionVerb).toLowerCase()
|
#. placeholder {0}: _(actionVerb).toLowerCase()
|
||||||
#: packages/email/template-components/template-document-invite.tsx
|
#: packages/email/template-components/template-document-invite.tsx
|
||||||
msgid "{inviterName} has invited you to {0}<0/>\"{documentName}\""
|
msgid "{inviterName} has invited you to {0}<0/>\"{documentName}\""
|
||||||
msgstr "{inviterName} hat dich eingeladen, {0}<0/>\"{documentName}\""
|
msgstr "{inviterName} hat dich eingeladen, zu {0}<0/>\"{documentName}\""
|
||||||
|
|
||||||
#: packages/email/templates/document-invite.tsx
|
#: packages/email/templates/document-invite.tsx
|
||||||
msgid "{inviterName} has invited you to {action} {documentName}"
|
msgid "{inviterName} has invited you to {action} {documentName}"
|
||||||
msgstr "{inviterName} hat dich eingeladen, {action} {documentName}"
|
msgstr "{inviterName} hat dich eingeladen, zu {action} {documentName}"
|
||||||
|
|
||||||
#: packages/email/templates/document-invite.tsx
|
#: packages/email/templates/document-invite.tsx
|
||||||
msgid "{inviterName} has invited you to {action} the document \"{documentName}\"."
|
msgid "{inviterName} has invited you to {action} the document \"{documentName}\"."
|
||||||
msgstr "{inviterName} hat Sie eingeladen, das Dokument \"{documentName}\" {action}."
|
msgstr "{inviterName} hat Sie eingeladen, das Dokument \"{documentName}\" zu {action}."
|
||||||
|
|
||||||
#: packages/email/templates/recipient-removed-from-document.tsx
|
#: packages/email/templates/recipient-removed-from-document.tsx
|
||||||
msgid "{inviterName} has removed you from the document {documentName}."
|
msgid "{inviterName} has removed you from the document {documentName}."
|
||||||
@@ -429,16 +429,16 @@ msgstr "{inviterName} hat dich aus dem Dokument<0/>\"{documentName}\" entfernt"
|
|||||||
#. placeholder {1}: envelope.title
|
#. placeholder {1}: envelope.title
|
||||||
#: packages/lib/jobs/definitions/emails/send-signing-email.handler.ts
|
#: packages/lib/jobs/definitions/emails/send-signing-email.handler.ts
|
||||||
msgid "{inviterName} on behalf of \"{0}\" has invited you to {recipientActionVerb} the document \"{1}\"."
|
msgid "{inviterName} on behalf of \"{0}\" has invited you to {recipientActionVerb} the document \"{1}\"."
|
||||||
msgstr "{inviterName} im Auftrag von \"{0}\" hat Sie eingeladen, das Dokument \"{1}\" {recipientActionVerb}."
|
msgstr "{inviterName} im Auftrag von \"{0}\" hat Sie eingeladen, das Dokument \"{1}\" zu {recipientActionVerb}."
|
||||||
|
|
||||||
#. placeholder {0}: _(actionVerb).toLowerCase()
|
#. placeholder {0}: _(actionVerb).toLowerCase()
|
||||||
#: packages/email/template-components/template-document-invite.tsx
|
#: packages/email/template-components/template-document-invite.tsx
|
||||||
msgid "{inviterName} on behalf of \"{teamName}\" has invited you to {0}<0/>\"{documentName}\""
|
msgid "{inviterName} on behalf of \"{teamName}\" has invited you to {0}<0/>\"{documentName}\""
|
||||||
msgstr "{inviterName} im Namen von \"{teamName}\" hat dich eingeladen, {0}<0/>\"{documentName}\""
|
msgstr "{inviterName} im Namen von \"{teamName}\" hat dich eingeladen, zu {0}<0/>\"{documentName}\""
|
||||||
|
|
||||||
#: packages/email/templates/document-invite.tsx
|
#: packages/email/templates/document-invite.tsx
|
||||||
msgid "{inviterName} on behalf of \"{teamName}\" has invited you to {action} {documentName}"
|
msgid "{inviterName} on behalf of \"{teamName}\" has invited you to {action} {documentName}"
|
||||||
msgstr "{inviterName} im Namen von \"{teamName}\" hat Sie eingeladen, das Dokument {documentName} {action}"
|
msgstr "{inviterName} im Namen von \"{teamName}\" hat Sie eingeladen, das Dokument {documentName} zu {action}"
|
||||||
|
|
||||||
#: apps/remix/app/components/dialogs/envelopes-bulk-download-dialog.tsx
|
#: apps/remix/app/components/dialogs/envelopes-bulk-download-dialog.tsx
|
||||||
msgid "{MAX_BULK_DOWNLOAD_ENVELOPES, plural, one {You can download up to # document at a time. Deselect some documents to continue.} other {You can download up to # documents at a time. Deselect some documents to continue.}}"
|
msgid "{MAX_BULK_DOWNLOAD_ENVELOPES, plural, one {You can download up to # document at a time. Deselect some documents to continue.} other {You can download up to # documents at a time. Deselect some documents to continue.}}"
|
||||||
@@ -517,11 +517,11 @@ msgstr "{subscriptionClaimCount, plural, one {# Abonnementsanforderung} other {#
|
|||||||
#. placeholder {0}: _(actionVerb).toLowerCase()
|
#. placeholder {0}: _(actionVerb).toLowerCase()
|
||||||
#: packages/email/template-components/template-document-invite.tsx
|
#: packages/email/template-components/template-document-invite.tsx
|
||||||
msgid "{teamName} has invited you to {0}<0/>\"{documentName}\""
|
msgid "{teamName} has invited you to {0}<0/>\"{documentName}\""
|
||||||
msgstr "{teamName} hat dich eingeladen, {0}<0/>\"{documentName}\""
|
msgstr "{teamName} hat dich eingeladen, zu {0}<0/>\"{documentName}\""
|
||||||
|
|
||||||
#: packages/email/templates/document-invite.tsx
|
#: packages/email/templates/document-invite.tsx
|
||||||
msgid "{teamName} has invited you to {action} {documentName}"
|
msgid "{teamName} has invited you to {action} {documentName}"
|
||||||
msgstr "{teamName} hat Sie eingeladen, {action} {documentName}"
|
msgstr "{teamName} hat Sie eingeladen, zu {action} {documentName}"
|
||||||
|
|
||||||
#: apps/remix/app/components/general/app-command-menu.tsx
|
#: apps/remix/app/components/general/app-command-menu.tsx
|
||||||
msgid "{totalVisibleCount, plural, one {# item} other {# items}}"
|
msgid "{totalVisibleCount, plural, one {# item} other {# items}}"
|
||||||
@@ -9344,7 +9344,7 @@ msgstr "Erinnerung: {0}"
|
|||||||
#: packages/lib/jobs/definitions/internal/process-signing-reminder.handler.ts
|
#: packages/lib/jobs/definitions/internal/process-signing-reminder.handler.ts
|
||||||
#: packages/lib/server-only/document/resend-document.ts
|
#: packages/lib/server-only/document/resend-document.ts
|
||||||
msgid "Reminder: {0} invited you to {recipientActionVerb} a document"
|
msgid "Reminder: {0} invited you to {recipientActionVerb} a document"
|
||||||
msgstr "Erinnerung: {0} hat dich eingeladen, ein Dokument {recipientActionVerb}"
|
msgstr "Erinnerung: {0} hat dich eingeladen, ein Dokument zu {recipientActionVerb}"
|
||||||
|
|
||||||
#. placeholder {0}: _(actionVerb).toLowerCase()
|
#. placeholder {0}: _(actionVerb).toLowerCase()
|
||||||
#: packages/email/template-components/template-document-reminder.tsx
|
#: packages/email/template-components/template-document-reminder.tsx
|
||||||
|
|||||||
@@ -125,3 +125,14 @@ 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.
|
||||||
|
*
|
||||||
|
* 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(0, 'Signing order must be equal to or greater than 0');
|
||||||
|
|||||||
@@ -0,0 +1,721 @@
|
|||||||
|
import { RecipientRole, SigningStatus } from '@prisma/client';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
extractRecipientToNewStep,
|
||||||
|
getNextDictatableRecipient,
|
||||||
|
getRecipientsInActiveSigningStep,
|
||||||
|
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('places each recipient without a signing order in its own step, after numbered steps, by id', () => {
|
||||||
|
const recipients = [
|
||||||
|
{ id: 30, formId: 'c', role: RecipientRole.SIGNER, signingOrder: null },
|
||||||
|
{ id: 20, formId: 'b', role: RecipientRole.SIGNER, signingOrder: null },
|
||||||
|
{ id: 40, formId: 'a', role: RecipientRole.SIGNER, signingOrder: 1 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const { steps } = groupRecipientsBySigningOrder(recipients);
|
||||||
|
|
||||||
|
expect(steps.map((step) => step.order)).toEqual([1, null, null]);
|
||||||
|
expect(steps.map((step) => step.members.map((m) => m.formId))).toEqual([['a'], ['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('numbers an editable unordered recipient after a numbered locked step', () => {
|
||||||
|
const recipients = [
|
||||||
|
{ id: 2, formId: 'b', role: RecipientRole.SIGNER, signingOrder: null },
|
||||||
|
{ id: 1, formId: 'locked', role: RecipientRole.SIGNER, signingOrder: 1 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const normalized = normalizeGroupedSigningOrders(recipients, (r) => r.formId !== 'locked');
|
||||||
|
|
||||||
|
expect(normalized.map((r) => [r.formId, r.signingOrder])).toEqual([
|
||||||
|
['locked', 1],
|
||||||
|
['b', 2],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Numbers sort ahead of unordered rows, so giving 'b' or the new signer a
|
||||||
|
// number would move them in front of the locked recipient who already acted.
|
||||||
|
it('keeps everything unordered behind a locked recipient without an order', () => {
|
||||||
|
const recipients = [
|
||||||
|
{ id: 1, formId: 'locked', role: RecipientRole.SIGNER, signingOrder: null },
|
||||||
|
{ id: 2, formId: 'b', role: RecipientRole.SIGNER, signingOrder: null },
|
||||||
|
{ formId: 'new', role: RecipientRole.SIGNER, signingOrder: undefined },
|
||||||
|
{ formId: 'cc', role: RecipientRole.CC, signingOrder: undefined },
|
||||||
|
];
|
||||||
|
|
||||||
|
const normalized = normalizeGroupedSigningOrders(recipients, (r) => r.formId !== 'locked');
|
||||||
|
|
||||||
|
expect(normalized.map((r) => [r.formId, r.signingOrder])).toEqual([
|
||||||
|
['locked', undefined],
|
||||||
|
['b', undefined],
|
||||||
|
['new', undefined],
|
||||||
|
['cc', undefined],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('editor operations behind a locked unordered recipient', () => {
|
||||||
|
const frozen = () => [
|
||||||
|
{ id: 1, formId: 'locked', role: RecipientRole.SIGNER, signingOrder: null },
|
||||||
|
{ id: 2, formId: 'b', role: RecipientRole.SIGNER, signingOrder: null },
|
||||||
|
{ id: 3, formId: 'c', role: RecipientRole.SIGNER, signingOrder: null },
|
||||||
|
];
|
||||||
|
|
||||||
|
const canUpdate = (r: { formId: string }) => r.formId !== 'locked';
|
||||||
|
|
||||||
|
const positions = (signers: Array<{ formId: string; signingOrder?: number }>) =>
|
||||||
|
signers.map((signer) => [signer.formId, signer.signingOrder]);
|
||||||
|
|
||||||
|
const expected = [
|
||||||
|
['locked', undefined],
|
||||||
|
['b', undefined],
|
||||||
|
['c', undefined],
|
||||||
|
];
|
||||||
|
|
||||||
|
it('refuses to number or move anything', () => {
|
||||||
|
expect(positions(reorderStep(frozen(), 2, 1, canUpdate))).toEqual(expected);
|
||||||
|
expect(positions(extractRecipientToNewStep(frozen(), 'b', 3, canUpdate))).toEqual(expected);
|
||||||
|
expect(positions(mergeSteps(frozen(), 2, 1, canUpdate))).toEqual(expected);
|
||||||
|
expect(positions(moveRecipientToStep(frozen(), 'c', 1, canUpdate))).toEqual(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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('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('sequences recipients without a signing order one at a time by id', () => {
|
||||||
|
const recipients = [
|
||||||
|
recipient(1, 1, SigningStatus.SIGNED),
|
||||||
|
recipient(3, null, SigningStatus.NOT_SIGNED),
|
||||||
|
recipient(2, null, SigningStatus.NOT_SIGNED),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(isRecipientTurnBySigningOrder(recipients, recipients[2])).toBe(true);
|
||||||
|
expect(isRecipientTurnBySigningOrder(recipients, recipients[1])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('orders group members by id when strictly sequential', () => {
|
||||||
|
const recipients = [recipient(2, 1, SigningStatus.NOT_SIGNED), recipient(1, 1, SigningStatus.NOT_SIGNED)];
|
||||||
|
|
||||||
|
expect(isRecipientTurnBySigningOrder(recipients, recipients[0], { strictlySequential: true })).toBe(false);
|
||||||
|
expect(isRecipientTurnBySigningOrder(recipients, recipients[1], { strictlySequential: true })).toBe(true);
|
||||||
|
expect(isRecipientTurnBySigningOrder(recipients, recipients[0])).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getRecipientsInActiveSigningStep', () => {
|
||||||
|
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(getRecipientsInActiveSigningStep(recipients).map((r) => r.id)).toEqual([3, 4]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty array for no pending recipients', () => {
|
||||||
|
expect(getRecipientsInActiveSigningStep([])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes recipients that have already signed', () => {
|
||||||
|
const recipients = [candidate(1, 1, SigningStatus.SIGNED), candidate(2, 2)];
|
||||||
|
|
||||||
|
expect(getRecipientsInActiveSigningStep(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(getRecipientsInActiveSigningStep(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(getRecipientsInActiveSigningStep(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(getRecipientsInActiveSigningStep(recipients)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('activates a single unordered recipient at a time, lowest id first', () => {
|
||||||
|
const recipients = [candidate(7, null), candidate(5, null), candidate(9, null)];
|
||||||
|
|
||||||
|
expect(getRecipientsInActiveSigningStep(recipients).map((r) => r.id)).toEqual([5]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('activates only the lowest id of a group when strictly sequential', () => {
|
||||||
|
const recipients = [candidate(4, 1), candidate(3, 1), candidate(5, 2)];
|
||||||
|
|
||||||
|
expect(getRecipientsInActiveSigningStep(recipients, { strictlySequential: true }).map((r) => r.id)).toEqual([3]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats the next unordered recipient by id as a single-recipient step', () => {
|
||||||
|
const recipients = [
|
||||||
|
recipient(1, null, SigningStatus.NOT_SIGNED),
|
||||||
|
recipient(2, null, SigningStatus.NOT_SIGNED),
|
||||||
|
recipient(3, null, SigningStatus.NOT_SIGNED),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(getNextDictatableRecipient({ recipients, currentRecipientId: 1 })?.id).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,486 @@
|
|||||||
|
import type { Recipient } from '@prisma/client';
|
||||||
|
import { SigningStatus } from '@prisma/client';
|
||||||
|
|
||||||
|
import type { PositionedRecipient } from './recipients';
|
||||||
|
import {
|
||||||
|
hasSigningOrder,
|
||||||
|
isCcRecipient,
|
||||||
|
isRecipientBefore,
|
||||||
|
isSameSigningStep,
|
||||||
|
sortRecipientsBySigningPosition,
|
||||||
|
} from './recipients';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A recipient "step" is the set of non-CC recipients sharing an explicit
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* A recipient without a signing order is always a single-member step (see
|
||||||
|
* `PositionedRecipient`).
|
||||||
|
*/
|
||||||
|
|
||||||
|
type GroupableRecipient = Pick<Recipient, 'role'> & PositionedRecipient;
|
||||||
|
|
||||||
|
export type RecipientStep<T> = {
|
||||||
|
/**
|
||||||
|
* Null for a legacy unordered recipient.
|
||||||
|
*/
|
||||||
|
order: number | null;
|
||||||
|
members: T[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const groupRecipientsBySigningOrder = <T extends GroupableRecipient>(recipients: T[]) => {
|
||||||
|
const ccRecipients = recipients.filter((recipient) => isCcRecipient(recipient));
|
||||||
|
const nonCcRecipients = sortRecipientsBySigningPosition(recipients.filter((recipient) => !isCcRecipient(recipient)));
|
||||||
|
|
||||||
|
const steps: RecipientStep<T>[] = [];
|
||||||
|
|
||||||
|
for (const recipient of nonCcRecipients) {
|
||||||
|
const lastStep = steps[steps.length - 1];
|
||||||
|
|
||||||
|
if (lastStep && lastStep.order !== null && isSameSigningStep(lastStep.members[0], recipient)) {
|
||||||
|
lastStep.members.push(recipient);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
steps.push({ order: hasSigningOrder(recipient) ? recipient.signingOrder : null, members: [recipient] });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { steps, ccRecipients };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Index of the last step containing a locked (non-updatable) recipient, or -1.
|
||||||
|
*/
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Numbers sort ahead of unordered recipients, so once a locked recipient holds
|
||||||
|
* no order, numbering anything behind it would move that recipient ahead of
|
||||||
|
* someone who has already acted. Everything must stay unordered, sequenced by id.
|
||||||
|
*/
|
||||||
|
export const isSigningOrderFrozen = <T extends GroupableRecipient>(
|
||||||
|
steps: RecipientStep<T>[],
|
||||||
|
canUpdateRecipient: (recipient: T) => boolean = () => true,
|
||||||
|
): boolean => {
|
||||||
|
const lastLockedStepIndex = getLastLockedStepIndex(steps, canUpdateRecipient);
|
||||||
|
|
||||||
|
return lastLockedStepIndex !== -1 && steps[lastLockedStepIndex].order === null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dense-renumbers steps to 1..K while preserving groups (duplicate orders).
|
||||||
|
*
|
||||||
|
* - Locked steps keep their persisted order
|
||||||
|
* - Editable steps never collide into a locked step's number
|
||||||
|
* - A frozen ordering (see `isSigningOrderFrozen`) is returned untouched
|
||||||
|
* - CC recipients move to the tail with an undefined order
|
||||||
|
* - 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);
|
||||||
|
const isFrozen = isSigningOrderFrozen(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.
|
||||||
|
if (isFrozen || index <= lastLockedStepIndex) {
|
||||||
|
const order = step.order ?? undefined;
|
||||||
|
|
||||||
|
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 };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Editor operations work on the normalized state so every editable step
|
||||||
|
* carries a number.
|
||||||
|
*/
|
||||||
|
const prepareEditorRecipients = <T extends EditorRecipient>(
|
||||||
|
recipients: T[],
|
||||||
|
canUpdateRecipient: (recipient: T) => boolean = () => true,
|
||||||
|
) => {
|
||||||
|
const normalized = normalizeGroupedSigningOrders(recipients, canUpdateRecipient);
|
||||||
|
const { steps, ccRecipients } = groupRecipientsBySigningOrder(normalized);
|
||||||
|
|
||||||
|
return {
|
||||||
|
recipients: normalized,
|
||||||
|
steps,
|
||||||
|
ccRecipients,
|
||||||
|
lastLockedStepIndex: getLastLockedStepIndex(steps, canUpdateRecipient),
|
||||||
|
isFrozen: isSigningOrderFrozen(steps, canUpdateRecipient),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 prepared = prepareEditorRecipients(recipients, canUpdateRecipient);
|
||||||
|
|
||||||
|
const sourceStep = prepared.steps[sourceStepIndex];
|
||||||
|
const targetStep = prepared.steps[targetStepIndex];
|
||||||
|
|
||||||
|
if (
|
||||||
|
prepared.isFrozen ||
|
||||||
|
!sourceStep ||
|
||||||
|
!targetStep ||
|
||||||
|
targetStep.order === null ||
|
||||||
|
sourceStepIndex === targetStepIndex ||
|
||||||
|
sourceStepIndex <= prepared.lastLockedStepIndex ||
|
||||||
|
targetStepIndex <= prepared.lastLockedStepIndex
|
||||||
|
) {
|
||||||
|
return prepared.recipients;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceFormIds = new Set(sourceStep.members.map((member) => member.formId));
|
||||||
|
|
||||||
|
// Source members join after the target step's existing members.
|
||||||
|
const remaining = prepared.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 prepared = prepareEditorRecipients(recipients, canUpdateRecipient);
|
||||||
|
|
||||||
|
const targetStep = prepared.steps[targetStepIndex];
|
||||||
|
const mover = prepared.recipients.find((recipient) => recipient.formId === formId);
|
||||||
|
const moverStepIndex = prepared.steps.findIndex((step) => step.members.some((member) => member.formId === formId));
|
||||||
|
|
||||||
|
if (prepared.isFrozen || !targetStep || targetStep.order === null || !mover || isCcRecipient(mover)) {
|
||||||
|
return prepared.recipients;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neither the recipient nor the destination may sit in the locked region.
|
||||||
|
if (targetStepIndex <= prepared.lastLockedStepIndex || moverStepIndex <= prepared.lastLockedStepIndex) {
|
||||||
|
return prepared.recipients;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetStep.members.some((member) => member.formId === formId)) {
|
||||||
|
return prepared.recipients;
|
||||||
|
}
|
||||||
|
|
||||||
|
const remaining = prepared.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 prepared = prepareEditorRecipients(recipients, canUpdateRecipient);
|
||||||
|
|
||||||
|
const mover = prepared.recipients.find((recipient) => recipient.formId === formId);
|
||||||
|
|
||||||
|
if (prepared.isFrozen || !mover || isCcRecipient(mover)) {
|
||||||
|
return prepared.recipients;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentStepIndex = prepared.steps.findIndex((step) => step.members.some((member) => member.formId === formId));
|
||||||
|
const isSoloStep = currentStepIndex !== -1 && prepared.steps[currentStepIndex].members.length === 1;
|
||||||
|
|
||||||
|
// Dropping a solo step into the gap directly above or below itself is a no-op.
|
||||||
|
if (isSoloStep && (insertStepIndex === currentStepIndex || insertStepIndex === currentStepIndex + 1)) {
|
||||||
|
return prepared.recipients;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 <= prepared.lastLockedStepIndex || currentStepIndex <= prepared.lastLockedStepIndex) {
|
||||||
|
return prepared.recipients;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every step past the locked region is numbered after normalization.
|
||||||
|
const lastStepOrder = prepared.steps[prepared.steps.length - 1]?.order ?? 0;
|
||||||
|
const insertStepOrder = prepared.steps[insertStepIndex]?.order;
|
||||||
|
|
||||||
|
const insertOrder =
|
||||||
|
insertStepIndex >= prepared.steps.length || insertStepOrder === null || insertStepOrder === undefined
|
||||||
|
? lastStepOrder + 1
|
||||||
|
: insertStepOrder - 0.5;
|
||||||
|
|
||||||
|
const updated = prepared.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; 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 prepared = prepareEditorRecipients(recipients, canUpdateRecipient);
|
||||||
|
|
||||||
|
if (
|
||||||
|
prepared.isFrozen ||
|
||||||
|
!prepared.steps[fromStepIndex] ||
|
||||||
|
fromStepIndex === toStepIndex ||
|
||||||
|
fromStepIndex <= prepared.lastLockedStepIndex ||
|
||||||
|
toStepIndex <= prepared.lastLockedStepIndex
|
||||||
|
) {
|
||||||
|
return prepared.recipients;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reorderedSteps = [...prepared.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, prepared.lastLockedStepIndex + 1)
|
||||||
|
.reduce((highest, step) => (step.order === null ? highest : Math.max(highest, step.order)), 0);
|
||||||
|
|
||||||
|
const updated = [
|
||||||
|
...reorderedSteps.flatMap((step, index) => {
|
||||||
|
if (index <= prepared.lastLockedStepIndex) {
|
||||||
|
return step.members;
|
||||||
|
}
|
||||||
|
|
||||||
|
const order = highestLockedOrder + (index - prepared.lastLockedStepIndex);
|
||||||
|
|
||||||
|
return step.members.map((member) => ({ ...member, signingOrder: order }));
|
||||||
|
}),
|
||||||
|
...prepared.ccRecipients,
|
||||||
|
];
|
||||||
|
|
||||||
|
return normalizeGroupedSigningOrders(updated, canUpdateRecipient);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 prepared = prepareEditorRecipients(recipients, canUpdateRecipient);
|
||||||
|
|
||||||
|
const step = prepared.steps[stepIndex];
|
||||||
|
|
||||||
|
// Splitting a locked step would rewrite persisted orders.
|
||||||
|
if (
|
||||||
|
prepared.isFrozen ||
|
||||||
|
!step ||
|
||||||
|
step.order === null ||
|
||||||
|
step.members.length < 2 ||
|
||||||
|
stepIndex <= prepared.lastLockedStepIndex
|
||||||
|
) {
|
||||||
|
return prepared.recipients;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stepOrder = step.order;
|
||||||
|
const offsetByFormId = new Map(step.members.map((member, index) => [member.formId, index]));
|
||||||
|
|
||||||
|
const updated = prepared.recipients.map((recipient) => {
|
||||||
|
const offset = offsetByFormId.get(recipient.formId);
|
||||||
|
|
||||||
|
if (offset === undefined) {
|
||||||
|
return recipient;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...recipient, signingOrder: stepOrder + offset / (step.members.length + 1) };
|
||||||
|
});
|
||||||
|
|
||||||
|
return normalizeGroupedSigningOrders(updated, canUpdateRecipient);
|
||||||
|
};
|
||||||
|
|
||||||
|
type SignableRecipient = Pick<Recipient, 'role' | 'signingStatus'> & PositionedRecipient;
|
||||||
|
|
||||||
|
type SequencingOptions = {
|
||||||
|
/**
|
||||||
|
* See `isRecipientBefore`. Required for AES/QES envelopes.
|
||||||
|
*/
|
||||||
|
strictlySequential?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether it is the recipient's turn to act under SEQUENTIAL signing.
|
||||||
|
*
|
||||||
|
* - A recipient may act once every non-CC recipient positioned before them has signed.
|
||||||
|
* - Recipients sharing an explicit signing order never block each other, unless
|
||||||
|
* `strictlySequential` is set.
|
||||||
|
* - Callers must check the document is in SEQUENTIAL mode.
|
||||||
|
*/
|
||||||
|
export const isRecipientTurnBySigningOrder = <T extends SignableRecipient>(
|
||||||
|
recipients: T[],
|
||||||
|
currentRecipient: PositionedRecipient,
|
||||||
|
options: SequencingOptions = {},
|
||||||
|
): boolean =>
|
||||||
|
!recipients.some(
|
||||||
|
(recipient) =>
|
||||||
|
!isCcRecipient(recipient) &&
|
||||||
|
recipient.signingStatus !== SigningStatus.SIGNED &&
|
||||||
|
isRecipientBefore(recipient, currentRecipient, options),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every pending recipient in the earliest pending step — the "active step".
|
||||||
|
*
|
||||||
|
* - Two or more members form a signing group and act in parallel, unless
|
||||||
|
* `strictlySequential` is set, in which case only the first member by id is
|
||||||
|
* active.
|
||||||
|
* - Pending means non-CC and NOT_SIGNED; rejected recipients are excluded so
|
||||||
|
* the flow never re-activates somebody who declined.
|
||||||
|
* - Pass the full recipient list: filtering happens here so every caller
|
||||||
|
* agrees on what "pending" means.
|
||||||
|
*/
|
||||||
|
export const getRecipientsInActiveSigningStep = <T extends SignableRecipient>(
|
||||||
|
recipients: T[],
|
||||||
|
options: SequencingOptions = {},
|
||||||
|
): T[] => {
|
||||||
|
const pendingRecipients = sortRecipientsBySigningPosition(
|
||||||
|
recipients.filter((recipient) => !isCcRecipient(recipient) && recipient.signingStatus === SigningStatus.NOT_SIGNED),
|
||||||
|
);
|
||||||
|
|
||||||
|
const [first] = pendingRecipients;
|
||||||
|
|
||||||
|
if (!first) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeStep = pendingRecipients.filter(
|
||||||
|
(recipient) => recipient === first || isSameSigningStep(recipient, first),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!options.strictlySequential) {
|
||||||
|
return activeStep;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
activeStep.reduce((earliest, recipient) =>
|
||||||
|
isRecipientBefore(recipient, earliest, options) ? recipient : earliest,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 pending 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 hasUnsignedPeers = recipients.some(
|
||||||
|
(recipient) =>
|
||||||
|
recipient.id !== currentRecipientId &&
|
||||||
|
!isCcRecipient(recipient) &&
|
||||||
|
isSameSigningStep(recipient, currentRecipient) &&
|
||||||
|
recipient.signingStatus !== SigningStatus.SIGNED,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasUnsignedPeers) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only the step matters here; `getRecipientsInActiveSigningStep` drops
|
||||||
|
// CCs and anyone who has already signed or rejected.
|
||||||
|
const laterRecipients = recipients.filter((recipient) => isRecipientBefore(currentRecipient, recipient));
|
||||||
|
|
||||||
|
const nextStep = getRecipientsInActiveSigningStep(laterRecipients);
|
||||||
|
|
||||||
|
if (nextStep.length !== 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextStep[0];
|
||||||
|
};
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { RecipientRole } from '@prisma/client';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { getLaterSigningStepRecipientsWhereInput, getRecipientFieldsWhereInput } from './recipient-queries';
|
||||||
|
|
||||||
|
describe('getLaterSigningStepRecipientsWhereInput', () => {
|
||||||
|
it('follows a numbered assistant with higher numbers and every unordered recipient', () => {
|
||||||
|
const where = getLaterSigningStepRecipientsWhereInput({ id: 10, signingOrder: 2, envelopeId: 'envelope_1' });
|
||||||
|
|
||||||
|
expect(where).toEqual({
|
||||||
|
envelopeId: 'envelope_1',
|
||||||
|
OR: [{ signingOrder: { gt: 2 } }, { signingOrder: null }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('follows an unordered assistant with later unordered recipients only', () => {
|
||||||
|
const where = getLaterSigningStepRecipientsWhereInput({ id: 10, signingOrder: null, envelopeId: 'envelope_1' });
|
||||||
|
|
||||||
|
expect(where).toEqual({
|
||||||
|
envelopeId: 'envelope_1',
|
||||||
|
signingOrder: null,
|
||||||
|
id: { gt: 10 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// `{ gt: undefined }` / `{ gt: NaN }` is silently dropped by Prisma, which
|
||||||
|
// would invert the predicate into match-everything. Fail closed instead.
|
||||||
|
it('throws when a non-finite id or order bypasses the types', () => {
|
||||||
|
expect(() =>
|
||||||
|
getLaterSigningStepRecipientsWhereInput({
|
||||||
|
id: undefined as unknown as number,
|
||||||
|
signingOrder: null,
|
||||||
|
envelopeId: 'envelope_1',
|
||||||
|
}),
|
||||||
|
).toThrow();
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
getLaterSigningStepRecipientsWhereInput({ id: 10, signingOrder: NaN, envelopeId: 'envelope_1' }),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getRecipientFieldsWhereInput', () => {
|
||||||
|
const assistant = {
|
||||||
|
id: 10,
|
||||||
|
role: RecipientRole.ASSISTANT,
|
||||||
|
signingOrder: 2,
|
||||||
|
envelopeId: 'envelope_1',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('restricts non-assistants and disallowed assistants to their own recipient row', () => {
|
||||||
|
expect(
|
||||||
|
getRecipientFieldsWhereInput({
|
||||||
|
recipient: { ...assistant, role: RecipientRole.SIGNER },
|
||||||
|
allowAssistantAccessToOtherRecipients: true,
|
||||||
|
}),
|
||||||
|
).toEqual({ id: 10 });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getRecipientFieldsWhereInput({
|
||||||
|
recipient: assistant,
|
||||||
|
allowAssistantAccessToOtherRecipients: false,
|
||||||
|
}),
|
||||||
|
).toEqual({ id: 10 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scopes assistant access to unsigned recipients positioned after them in the same envelope', () => {
|
||||||
|
const where = getRecipientFieldsWhereInput({
|
||||||
|
recipient: assistant,
|
||||||
|
allowAssistantAccessToOtherRecipients: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(where).toEqual({
|
||||||
|
signingStatus: { not: 'SIGNED' },
|
||||||
|
envelopeId: 'envelope_1',
|
||||||
|
AND: [
|
||||||
|
{
|
||||||
|
envelopeId: 'envelope_1',
|
||||||
|
OR: [
|
||||||
|
{ id: 10 },
|
||||||
|
{
|
||||||
|
envelopeId: 'envelope_1',
|
||||||
|
OR: [{ signingOrder: { gt: 2 } }, { signingOrder: null }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import type { Prisma, Recipient } from '@prisma/client';
|
||||||
|
import { RecipientRole, SigningStatus } from '@prisma/client';
|
||||||
|
|
||||||
|
import { AppError, AppErrorCode } from '../errors/app-error';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prisma `where` input matching recipients in the assistant's envelope
|
||||||
|
* positioned strictly after the assistant, mirroring
|
||||||
|
* `compareRecipientSigningPosition`. Same-step peers are never included.
|
||||||
|
*/
|
||||||
|
export const getLaterSigningStepRecipientsWhereInput = (
|
||||||
|
assistant: Pick<Recipient, 'id' | 'signingOrder' | 'envelopeId'>,
|
||||||
|
): Prisma.RecipientWhereInput => {
|
||||||
|
// `{ gt: undefined }` is silently dropped by Prisma, turning the predicate
|
||||||
|
// into match-everything.
|
||||||
|
if (!Number.isFinite(assistant.id)) {
|
||||||
|
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||||
|
message: 'Assistant id must be a finite number',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (assistant.signingOrder === null || assistant.signingOrder === undefined) {
|
||||||
|
return {
|
||||||
|
envelopeId: assistant.envelopeId,
|
||||||
|
signingOrder: null,
|
||||||
|
id: {
|
||||||
|
gt: assistant.id,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isFinite(assistant.signingOrder)) {
|
||||||
|
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||||
|
message: 'Assistant signing order must be a finite number',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
envelopeId: assistant.envelopeId,
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
|
signingOrder: {
|
||||||
|
gt: assistant.signingOrder,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
signingOrder: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prisma `where` input matching every recipient an assistant may act for:
|
||||||
|
* themself, plus recipients positioned strictly after them — never their own
|
||||||
|
* group peers. Scoped to the assistant's envelope.
|
||||||
|
*/
|
||||||
|
export const getAssistableRecipientsWhereInput = (
|
||||||
|
assistant: Pick<Recipient, 'id' | 'signingOrder' | 'envelopeId'>,
|
||||||
|
): Prisma.RecipientWhereInput => ({
|
||||||
|
envelopeId: assistant.envelopeId,
|
||||||
|
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 positioned after
|
||||||
|
* them.
|
||||||
|
*
|
||||||
|
* Shared by every field-level endpoint (sign / uninsert, V1 and V2) so the
|
||||||
|
* RECIPIENT scoping rule cannot drift between them.
|
||||||
|
*/
|
||||||
|
export const getRecipientFieldsWhereInput = ({
|
||||||
|
recipient,
|
||||||
|
allowAssistantAccessToOtherRecipients,
|
||||||
|
}: {
|
||||||
|
recipient: Pick<Recipient, 'id' | 'role' | 'signingOrder' | 'envelopeId'>;
|
||||||
|
allowAssistantAccessToOtherRecipients: boolean;
|
||||||
|
}): Prisma.RecipientWhereInput => {
|
||||||
|
if (recipient.role !== RecipientRole.ASSISTANT || !allowAssistantAccessToOtherRecipients) {
|
||||||
|
return { id: recipient.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
signingStatus: {
|
||||||
|
not: SigningStatus.SIGNED,
|
||||||
|
},
|
||||||
|
envelopeId: recipient.envelopeId,
|
||||||
|
AND: [getAssistableRecipientsWhereInput(recipient)],
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -23,6 +23,34 @@ describe('recipient signing order helpers', () => {
|
|||||||
expect(sortRecipientsForSigningOrder(recipients).map((recipient) => recipient.id)).toEqual([2, 1]);
|
expect(sortRecipientsForSigningOrder(recipients).map((recipient) => recipient.id)).toEqual([2, 1]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('sorts recipients without a signing order after numbered ones, by id', () => {
|
||||||
|
const recipients = [
|
||||||
|
{ id: 3, role: RecipientRole.SIGNER, signingOrder: null },
|
||||||
|
{ id: 4, role: RecipientRole.CC, signingOrder: null },
|
||||||
|
{ id: 2, role: RecipientRole.SIGNER, signingOrder: null },
|
||||||
|
{ id: 5, role: RecipientRole.SIGNER, signingOrder: 9 },
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(sortRecipientsForSigningOrder(recipients).map((recipient) => recipient.id)).toEqual([5, 2, 3, 4]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats an unordered assistant as last only when no unordered recipient has a higher id', () => {
|
||||||
|
expect(
|
||||||
|
isAssistantLastSigner([
|
||||||
|
{ id: 2, role: RecipientRole.ASSISTANT, signingOrder: null },
|
||||||
|
{ id: 1, role: RecipientRole.SIGNER, signingOrder: null },
|
||||||
|
{ id: 3, role: RecipientRole.SIGNER, signingOrder: 1 },
|
||||||
|
]),
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
isAssistantLastSigner([
|
||||||
|
{ id: 1, role: RecipientRole.ASSISTANT, signingOrder: null },
|
||||||
|
{ id: 2, role: RecipientRole.SIGNER, signingOrder: null },
|
||||||
|
]),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('sorts and normalizes active recipient signing order and removes it from CC recipients', () => {
|
it('sorts and normalizes active recipient signing order and removes it from CC recipients', () => {
|
||||||
const recipients = [
|
const recipients = [
|
||||||
{ id: 1, role: RecipientRole.CC, signingOrder: 1 },
|
{ id: 1, role: RecipientRole.CC, signingOrder: 1 },
|
||||||
@@ -51,6 +79,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([
|
||||||
|
|||||||
@@ -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, 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';
|
||||||
@@ -16,17 +17,108 @@ import { zEmail } from './zod';
|
|||||||
export const RECIPIENT_ROLES_THAT_REQUIRE_FIELDS = [RecipientRole.SIGNER] as const;
|
export const RECIPIENT_ROLES_THAT_REQUIRE_FIELDS = [RecipientRole.SIGNER] as const;
|
||||||
|
|
||||||
// signingOrder isn't required when submitting the recipient form (Zod: z.number().optional())
|
// signingOrder isn't required when submitting the recipient form (Zod: z.number().optional())
|
||||||
type RecipientWithSigningOrder = Pick<Recipient, 'role'> & Partial<Pick<Recipient, 'signingOrder'>>;
|
type RecipientWithSigningOrder = Pick<Recipient, 'role'> & PositionedRecipient;
|
||||||
|
|
||||||
export const isCcRecipient = (recipient: Pick<Recipient, 'role'>) => {
|
export const isCcRecipient = (recipient: Pick<Recipient, 'role'>) => {
|
||||||
return recipient.role === RecipientRole.CC;
|
return recipient.role === RecipientRole.CC;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isAssistantLastSigner = (recipients: Pick<Recipient, 'role'>[]) => {
|
/**
|
||||||
const nonCcRecipients = recipients.filter((recipient) => !isCcRecipient(recipient));
|
* Recipients sharing an explicit signing order form a step and may act in
|
||||||
const lastNonCcRecipient = nonCcRecipients[nonCcRecipients.length - 1];
|
* parallel. A recipient without one (legacy rows predating automatic
|
||||||
|
* numbering) never shares a step: unordered recipients sort after every
|
||||||
|
* numbered recipient and among themselves by id, matching how the server has
|
||||||
|
* always processed them (`ORDER BY signingOrder NULLS LAST, id`).
|
||||||
|
*/
|
||||||
|
export type PositionedRecipient = {
|
||||||
|
id?: number | null;
|
||||||
|
signingOrder?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
return lastNonCcRecipient?.role === RecipientRole.ASSISTANT;
|
export const hasSigningOrder = (recipient: PositionedRecipient): recipient is { signingOrder: number } =>
|
||||||
|
typeof recipient.signingOrder === 'number';
|
||||||
|
|
||||||
|
const hasPersistedId = (recipient: PositionedRecipient): recipient is { id: number } =>
|
||||||
|
typeof recipient.id === 'number';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unsaved (id-less) unordered recipients sort last, in input order.
|
||||||
|
*/
|
||||||
|
export const compareRecipientSigningPosition = (a: PositionedRecipient, b: PositionedRecipient): number => {
|
||||||
|
const aIsNumbered = hasSigningOrder(a);
|
||||||
|
const bIsNumbered = hasSigningOrder(b);
|
||||||
|
|
||||||
|
if (aIsNumbered && bIsNumbered) {
|
||||||
|
return a.signingOrder - b.signingOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (aIsNumbered !== bIsNumbered) {
|
||||||
|
return aIsNumbered ? -1 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const aHasId = hasPersistedId(a);
|
||||||
|
const bHasId = hasPersistedId(b);
|
||||||
|
|
||||||
|
if (aHasId && bHasId) {
|
||||||
|
return a.id - b.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (aHasId !== bHasId) {
|
||||||
|
return aHasId ? -1 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sortRecipientsBySigningPosition = <T extends PositionedRecipient>(recipients: T[]): T[] =>
|
||||||
|
[...recipients].sort(compareRecipientSigningPosition);
|
||||||
|
|
||||||
|
export const isSameSigningStep = (a: PositionedRecipient, b: PositionedRecipient): boolean =>
|
||||||
|
hasSigningOrder(a) && hasSigningOrder(b) && a.signingOrder === b.signingOrder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether `recipient` must act before `other`.
|
||||||
|
*
|
||||||
|
* `strictlySequential` also orders group members by id so no two recipients
|
||||||
|
* are ever eligible at once — required on AES/QES, where a TSP signature is
|
||||||
|
* computed over a document snapshot and overlapping signers would invalidate
|
||||||
|
* each other's /ByteRange.
|
||||||
|
*/
|
||||||
|
export const isRecipientBefore = (
|
||||||
|
recipient: PositionedRecipient,
|
||||||
|
other: PositionedRecipient,
|
||||||
|
options: { strictlySequential?: boolean } = {},
|
||||||
|
): boolean => {
|
||||||
|
const comparison = compareRecipientSigningPosition(recipient, other);
|
||||||
|
|
||||||
|
if (comparison !== 0) {
|
||||||
|
return comparison < 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!options.strictlySequential || !isSameSigningStep(recipient, other)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasPersistedId(recipient) && hasPersistedId(other) && recipient.id < other.id;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether an assistant sits in the last signing step (nobody after them to assist).
|
||||||
|
*/
|
||||||
|
export const isAssistantLastSigner = (recipients: RecipientWithSigningOrder[]) => {
|
||||||
|
const nonCcRecipients = sortRecipientsBySigningPosition(recipients.filter((recipient) => !isCcRecipient(recipient)));
|
||||||
|
|
||||||
|
const lastRecipient = nonCcRecipients[nonCcRecipients.length - 1];
|
||||||
|
|
||||||
|
if (!lastRecipient) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return nonCcRecipients.some(
|
||||||
|
(recipient) =>
|
||||||
|
recipient.role === RecipientRole.ASSISTANT &&
|
||||||
|
(recipient === lastRecipient || isSameSigningStep(recipient, lastRecipient)),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const sortRecipientsForSigningOrder = <T extends RecipientWithSigningOrder>(recipients: T[]): T[] => {
|
export const sortRecipientsForSigningOrder = <T extends RecipientWithSigningOrder>(recipients: T[]): T[] => {
|
||||||
@@ -39,11 +131,7 @@ export const sortRecipientsForSigningOrder = <T extends RecipientWithSigningOrde
|
|||||||
return r1IsCcRecipient ? 1 : -1;
|
return r1IsCcRecipient ? 1 : -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Order by signing order; missing orders sort last.
|
return compareRecipientSigningPosition(r1, r2);
|
||||||
const r1SigningOrder = r1.signingOrder ?? Number.MAX_SAFE_INTEGER;
|
|
||||||
const r2SigningOrder = r2.signingOrder ?? Number.MAX_SAFE_INTEGER;
|
|
||||||
|
|
||||||
return r1SigningOrder - r2SigningOrder;
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -120,6 +208,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(
|
||||||
|
|||||||
+6
-2
@@ -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(),
|
||||||
});
|
});
|
||||||
|
|||||||
+6
-2
@@ -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,6 +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 { getRecipientFieldsWhereInput } from '@documenso/lib/utils/recipient-queries';
|
||||||
import { assertRecipientNotExpired } from '@documenso/lib/utils/recipients';
|
import { assertRecipientNotExpired } 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';
|
||||||
@@ -39,20 +40,10 @@ export const signEnvelopeFieldRoute = procedure
|
|||||||
const field = await prisma.field.findFirst({
|
const field = await prisma.field.findFirst({
|
||||||
where: {
|
where: {
|
||||||
id: fieldId,
|
id: fieldId,
|
||||||
recipient:
|
recipient: getRecipientFieldsWhereInput({
|
||||||
recipient.role === RecipientRole.ASSISTANT
|
recipient,
|
||||||
? {
|
allowAssistantAccessToOtherRecipients: true,
|
||||||
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([]),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user