mirror of
https://github.com/documenso/documenso.git
synced 2026-06-22 04:12:06 +10:00
feat: validate signers have signature fields before distribution (#2411)
API users were inadvertently sending documents without signature fields, causing confusion for recipients and breaking their signing flows. - Add getRecipientsWithMissingFields helper in recipients.ts - Add server-side validation in sendDocument to block distribution - Fix v1 API to return 400 instead of 500 for validation errors - Consolidate UI signature field checks to use isSignatureFieldType - Add E2E tests for both v1 and v2 APIs
This commit is contained in:
@@ -0,0 +1,76 @@
|
|||||||
|
---
|
||||||
|
date: 2026-01-26
|
||||||
|
title: Validate Signer Fields On Distribute
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Validate that signers have at least one signature field before allowing document/envelope distribution via API, matching the existing UI behavior.
|
||||||
|
|
||||||
|
## Background
|
||||||
|
|
||||||
|
The API originally allowed distributing documents/envelopes without validating that signers had signature fields assigned. This was intentional - we thought API users might have specific flows where this flexibility was needed.
|
||||||
|
|
||||||
|
However, after running it this way for a while, we've observed that more often than not, API users inadvertently send documents without fields assigned. This causes confusion for their recipients (who receive a document with nothing to sign) and breaks their own systems expecting a completed signing flow.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The API allowed distributing documents/envelopes even when signers had no signature fields assigned. This was inconsistent with the UI which validates this condition before allowing distribution.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
### 1. Create centralized validation helper
|
||||||
|
|
||||||
|
**File**: `packages/lib/utils/recipients.ts`
|
||||||
|
|
||||||
|
- Added `RECIPIENT_ROLES_THAT_REQUIRE_FIELDS` constant (currently only `SIGNER`)
|
||||||
|
- Added `getRecipientsWithMissingFields()` function that returns recipients missing required fields
|
||||||
|
- Uses existing `isSignatureFieldType` guard from `packages/prisma/guards/is-signature-field.ts`
|
||||||
|
|
||||||
|
### 2. Add server-side validation
|
||||||
|
|
||||||
|
**File**: `packages/lib/server-only/document/send-document.ts`
|
||||||
|
|
||||||
|
- Added validation check that throws `AppError` with `INVALID_REQUEST` code when signers are missing signature fields
|
||||||
|
- This blocks both v1 and v2 API distribution endpoints since they both use `sendDocument()`
|
||||||
|
|
||||||
|
### 3. Fix v1 API error handling
|
||||||
|
|
||||||
|
**File**: `packages/api/v1/implementation.ts`
|
||||||
|
|
||||||
|
- Changed `sendDocument` endpoint to use `AppError.toRestAPIError(err)` instead of always returning 500
|
||||||
|
- Now returns 400 for validation errors
|
||||||
|
|
||||||
|
### 4. Update UI to use shared helper
|
||||||
|
|
||||||
|
**Files**:
|
||||||
|
|
||||||
|
- `apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx`
|
||||||
|
- `packages/ui/primitives/document-flow/add-fields.tsx`
|
||||||
|
|
||||||
|
### 5. Consolidate `hasSignatureField` checks
|
||||||
|
|
||||||
|
Updated to use `isSignatureFieldType` guard (checks both `SIGNATURE` and `FREE_SIGNATURE`):
|
||||||
|
|
||||||
|
- `apps/remix/app/components/general/document-signing/document-signing-form.tsx`
|
||||||
|
- `apps/remix/app/components/general/envelope-signing/envelope-signer-form.tsx`
|
||||||
|
- `apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx`
|
||||||
|
- `apps/remix/app/components/embed/embed-direct-template-client-page.tsx`
|
||||||
|
- `apps/remix/app/components/embed/embed-document-signing-page-v1.tsx`
|
||||||
|
|
||||||
|
### 6. Add E2E tests
|
||||||
|
|
||||||
|
**Files**:
|
||||||
|
|
||||||
|
- `packages/app-tests/e2e/api/v1/document-sending.spec.ts` - 5 new tests
|
||||||
|
- `packages/app-tests/e2e/api/v2/distribute-validation.spec.ts` - 8 new tests
|
||||||
|
|
||||||
|
## Test Coverage
|
||||||
|
|
||||||
|
- Distribution fails when signer has no fields
|
||||||
|
- Distribution fails when signer has only non-signature fields
|
||||||
|
- Distribution succeeds with SIGNATURE field
|
||||||
|
- Distribution succeeds with FREE_SIGNATURE field (v1 only via Prisma)
|
||||||
|
- Distribution succeeds when VIEWER/CC/APPROVER have no fields
|
||||||
|
- Distribution fails when one of multiple signers is missing signature field
|
||||||
|
- Distribution succeeds when all signers have signature fields
|
||||||
@@ -3,13 +3,7 @@ import { useEffect, useMemo, useState } from 'react';
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useLingui } from '@lingui/react/macro';
|
import { useLingui } from '@lingui/react/macro';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import {
|
import { DocumentDistributionMethod, DocumentStatus, EnvelopeType } from '@prisma/client';
|
||||||
DocumentDistributionMethod,
|
|
||||||
DocumentStatus,
|
|
||||||
EnvelopeType,
|
|
||||||
FieldType,
|
|
||||||
RecipientRole,
|
|
||||||
} from '@prisma/client';
|
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
import { InfoIcon } from 'lucide-react';
|
import { InfoIcon } from 'lucide-react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
@@ -20,6 +14,7 @@ import * as z from 'zod';
|
|||||||
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 { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||||
|
import { getRecipientsWithMissingFields } from '@documenso/lib/utils/recipients';
|
||||||
import { trpc, trpc as trpcReact } from '@documenso/trpc/react';
|
import { trpc, trpc as trpcReact } from '@documenso/trpc/react';
|
||||||
import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper';
|
import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@@ -140,14 +135,7 @@ export const EnvelopeDistributeDialog = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const recipientsMissingSignatureFields = useMemo(
|
const recipientsMissingSignatureFields = useMemo(
|
||||||
() =>
|
() => getRecipientsWithMissingFields(recipientsWithIndex, envelope.fields),
|
||||||
recipientsWithIndex.filter(
|
|
||||||
(recipient) =>
|
|
||||||
recipient.role === RecipientRole.SIGNER &&
|
|
||||||
!envelope.fields.some(
|
|
||||||
(field) => field.type === FieldType.SIGNATURE && field.recipientId === recipient.id,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
[recipientsWithIndex, envelope.fields],
|
[recipientsWithIndex, envelope.fields],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,14 @@ import { useEffect, useLayoutEffect, useState } from 'react';
|
|||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { DocumentMeta, EnvelopeItem, Recipient, Signature } from '@prisma/client';
|
import {
|
||||||
import { type Field, FieldType } from '@prisma/client';
|
type DocumentMeta,
|
||||||
|
type EnvelopeItem,
|
||||||
|
type Field,
|
||||||
|
FieldType,
|
||||||
|
type Recipient,
|
||||||
|
type Signature,
|
||||||
|
} from '@prisma/client';
|
||||||
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
|
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { useSearchParams } from 'react-router';
|
import { useSearchParams } from 'react-router';
|
||||||
@@ -18,6 +24,7 @@ import {
|
|||||||
isRequiredField,
|
isRequiredField,
|
||||||
} from '@documenso/lib/utils/advanced-fields-helpers';
|
} from '@documenso/lib/utils/advanced-fields-helpers';
|
||||||
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
|
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||||
|
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import type {
|
import type {
|
||||||
TRemovedSignedFieldWithTokenMutationSchema,
|
TRemovedSignedFieldWithTokenMutationSchema,
|
||||||
@@ -96,7 +103,7 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
|
|
||||||
const highestPendingPageNumber = Math.max(...pendingFields.map((field) => field.page));
|
const highestPendingPageNumber = Math.max(...pendingFields.map((field) => field.page));
|
||||||
|
|
||||||
const hasSignatureField = localFields.some((field) => field.type === FieldType.SIGNATURE);
|
const hasSignatureField = localFields.some((field) => isSignatureFieldType(field.type));
|
||||||
|
|
||||||
const signatureValid = !hasSignatureField || (signature && signature.trim() !== '');
|
const signatureValid = !hasSignatureField || (signature && signature.trim() !== '');
|
||||||
|
|
||||||
|
|||||||
@@ -4,13 +4,14 @@ import { msg } from '@lingui/core/macro';
|
|||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { DocumentMeta, EnvelopeItem } from '@prisma/client';
|
import type { DocumentMeta, EnvelopeItem } from '@prisma/client';
|
||||||
import { type Field, FieldType, RecipientRole, SigningStatus } from '@prisma/client';
|
import { type Field, RecipientRole, SigningStatus } from '@prisma/client';
|
||||||
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
|
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
|
||||||
|
|
||||||
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
|
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
|
||||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||||
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||||
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
|
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||||
|
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
|
||||||
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import {
|
import {
|
||||||
@@ -115,7 +116,7 @@ export const EmbedSignDocumentV1ClientPage = ({
|
|||||||
[fields],
|
[fields],
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasSignatureField = fields.some((field) => field.type === FieldType.SIGNATURE);
|
const hasSignatureField = fields.some((field) => isSignatureFieldType(field.type));
|
||||||
|
|
||||||
const signatureValid = !hasSignatureField || (signature && signature.trim() !== '');
|
const signatureValid = !hasSignatureField || (signature && signature.trim() !== '');
|
||||||
|
|
||||||
|
|||||||
@@ -3,12 +3,13 @@ import { useState } from 'react';
|
|||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { DocumentStatus, FieldType, SigningStatus } from '@prisma/client';
|
import { DocumentStatus, SigningStatus } from '@prisma/client';
|
||||||
import { Loader, LucideChevronDown, LucideChevronUp, X } from 'lucide-react';
|
import { Loader, LucideChevronDown, LucideChevronUp, X } from 'lucide-react';
|
||||||
import { P, match } from 'ts-pattern';
|
import { P, match } from 'ts-pattern';
|
||||||
|
|
||||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
|
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import type {
|
import type {
|
||||||
TRemovedSignedFieldWithTokenMutationSchema,
|
TRemovedSignedFieldWithTokenMutationSchema,
|
||||||
@@ -83,7 +84,7 @@ export const MultiSignDocumentSigningView = ({
|
|||||||
const { mutateAsync: completeDocumentWithToken } =
|
const { mutateAsync: completeDocumentWithToken } =
|
||||||
trpc.recipient.completeDocumentWithToken.useMutation();
|
trpc.recipient.completeDocumentWithToken.useMutation();
|
||||||
|
|
||||||
const hasSignatureField = document?.fields.some((field) => field.type === FieldType.SIGNATURE);
|
const hasSignatureField = document?.fields.some((field) => isSignatureFieldType(field.type));
|
||||||
|
|
||||||
const [pendingFields, completedFields] = [
|
const [pendingFields, completedFields] = [
|
||||||
document?.fields.filter((field) => field.recipient.signingStatus !== SigningStatus.SIGNED) ??
|
document?.fields.filter((field) => field.recipient.signingStatus !== SigningStatus.SIGNED) ??
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useId, useMemo, useState } from 'react';
|
|||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { type Field, FieldType, type Recipient, RecipientRole } from '@prisma/client';
|
import { type Field, type Recipient, RecipientRole } from '@prisma/client';
|
||||||
import { Controller, useForm } from 'react-hook-form';
|
import { Controller, useForm } from 'react-hook-form';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
|
|
||||||
@@ -11,6 +11,7 @@ import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-
|
|||||||
import type { TRecipientAccessAuth } from '@documenso/lib/types/document-auth';
|
import type { TRecipientAccessAuth } from '@documenso/lib/types/document-auth';
|
||||||
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||||
import { sortFieldsByPosition } from '@documenso/lib/utils/fields';
|
import { sortFieldsByPosition } from '@documenso/lib/utils/fields';
|
||||||
|
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
|
||||||
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
||||||
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@@ -78,7 +79,7 @@ export const DocumentSigningForm = ({
|
|||||||
[fields],
|
[fields],
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasSignatureField = fields.some((field) => field.type === FieldType.SIGNATURE);
|
const hasSignatureField = fields.some((field) => isSignatureFieldType(field.type));
|
||||||
|
|
||||||
const uninsertedFields = useMemo(() => {
|
const uninsertedFields = useMemo(() => {
|
||||||
return sortFieldsByPosition(fieldsRequiringValidation.filter((field) => !field.inserted));
|
return sortFieldsByPosition(fieldsRequiringValidation.filter((field) => !field.inserted));
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import { Plural, Trans } from '@lingui/react/macro';
|
import { Plural, Trans } from '@lingui/react/macro';
|
||||||
import { FieldType, RecipientRole } from '@prisma/client';
|
import { RecipientRole } from '@prisma/client';
|
||||||
|
|
||||||
|
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import { Label } from '@documenso/ui/primitives/label';
|
import { Label } from '@documenso/ui/primitives/label';
|
||||||
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
|
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
|
||||||
@@ -30,7 +31,7 @@ export default function EnvelopeSignerForm() {
|
|||||||
const { isNameLocked, isEmailLocked } = useEmbedSigningContext() || {};
|
const { isNameLocked, isEmailLocked } = useEmbedSigningContext() || {};
|
||||||
|
|
||||||
const hasSignatureField = useMemo(() => {
|
const hasSignatureField = useMemo(() => {
|
||||||
return recipientFields.some((field) => field.type === FieldType.SIGNATURE);
|
return recipientFields.some((field) => isSignatureFieldType(field.type));
|
||||||
}, [recipientFields]);
|
}, [recipientFields]);
|
||||||
|
|
||||||
const isSubmitting = false;
|
const isSubmitting = false;
|
||||||
|
|||||||
@@ -1041,12 +1041,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return {
|
return AppError.toRestAPIError(err);
|
||||||
status: 500,
|
|
||||||
body: {
|
|
||||||
message: 'An error has occured while sending the document for signing',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
|||||||
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||||
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
|
import { FieldType, RecipientRole } from '@documenso/prisma/client';
|
||||||
|
import {
|
||||||
|
seedBlankDocument,
|
||||||
|
seedPendingDocumentWithFullFields,
|
||||||
|
} from '@documenso/prisma/seed/documents';
|
||||||
import { seedUser } from '@documenso/prisma/seed/users';
|
import { seedUser } from '@documenso/prisma/seed/users';
|
||||||
|
|
||||||
test.describe('Document API', () => {
|
test.describe('Document API', () => {
|
||||||
@@ -145,4 +149,293 @@ test.describe('Document API', () => {
|
|||||||
ownerDocumentCompleted: false,
|
ownerDocumentCompleted: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('sendDocument: should fail when signer has no signature field', async ({ request }) => {
|
||||||
|
const { user, team } = await seedUser();
|
||||||
|
|
||||||
|
// Create a blank document and get it with envelope items
|
||||||
|
const blankDocument = await seedBlankDocument(user, team.id);
|
||||||
|
const document = await prisma.envelope.findUniqueOrThrow({
|
||||||
|
where: { id: blankDocument.id },
|
||||||
|
include: { envelopeItems: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add a signer recipient without any fields
|
||||||
|
await prisma.recipient.create({
|
||||||
|
data: {
|
||||||
|
email: 'signer@example.com',
|
||||||
|
name: 'Test Signer',
|
||||||
|
role: RecipientRole.SIGNER,
|
||||||
|
token: 'test-token-1',
|
||||||
|
envelopeId: document.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { token } = await createApiToken({
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team.id,
|
||||||
|
tokenName: 'test',
|
||||||
|
expiresIn: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await request.post(
|
||||||
|
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v1/documents/${mapSecondaryIdToDocumentId(document.secondaryId)}/send`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
data: {},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.ok()).toBeFalsy();
|
||||||
|
expect(response.status()).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sendDocument: should fail when signer has only non-signature fields', async ({
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const { user, team } = await seedUser();
|
||||||
|
|
||||||
|
// Create a blank document and get it with envelope items
|
||||||
|
const blankDocument = await seedBlankDocument(user, team.id);
|
||||||
|
const document = await prisma.envelope.findUniqueOrThrow({
|
||||||
|
where: { id: blankDocument.id },
|
||||||
|
include: { envelopeItems: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add a signer recipient with only a TEXT field (not signature)
|
||||||
|
const recipient = await prisma.recipient.create({
|
||||||
|
data: {
|
||||||
|
email: 'signer@example.com',
|
||||||
|
name: 'Test Signer',
|
||||||
|
role: RecipientRole.SIGNER,
|
||||||
|
token: 'test-token-2',
|
||||||
|
envelopeId: document.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add a TEXT field (not a signature field)
|
||||||
|
await prisma.field.create({
|
||||||
|
data: {
|
||||||
|
type: FieldType.TEXT,
|
||||||
|
page: 1,
|
||||||
|
positionX: 100,
|
||||||
|
positionY: 100,
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
customText: '',
|
||||||
|
inserted: false,
|
||||||
|
recipientId: recipient.id,
|
||||||
|
envelopeId: document.id,
|
||||||
|
envelopeItemId: document.envelopeItems[0].id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { token } = await createApiToken({
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team.id,
|
||||||
|
tokenName: 'test',
|
||||||
|
expiresIn: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await request.post(
|
||||||
|
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v1/documents/${mapSecondaryIdToDocumentId(document.secondaryId)}/send`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
data: {},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.ok()).toBeFalsy();
|
||||||
|
expect(response.status()).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sendDocument: should succeed when signer has signature field', async ({ request }) => {
|
||||||
|
const { user, team } = await seedUser();
|
||||||
|
|
||||||
|
const { document } = await seedPendingDocumentWithFullFields({
|
||||||
|
owner: user,
|
||||||
|
recipients: ['signer@example.com'],
|
||||||
|
teamId: team.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { token } = await createApiToken({
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team.id,
|
||||||
|
tokenName: 'test',
|
||||||
|
expiresIn: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await request.post(
|
||||||
|
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v1/documents/${mapSecondaryIdToDocumentId(document.secondaryId)}/send`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
data: {},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.ok()).toBeTruthy();
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sendDocument: should succeed when signer has FREE_SIGNATURE field', async ({ request }) => {
|
||||||
|
const { user, team } = await seedUser();
|
||||||
|
|
||||||
|
// Create a blank document and get it with envelope items
|
||||||
|
const blankDocument = await seedBlankDocument(user, team.id);
|
||||||
|
const document = await prisma.envelope.findUniqueOrThrow({
|
||||||
|
where: { id: blankDocument.id },
|
||||||
|
include: { envelopeItems: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add a signer recipient
|
||||||
|
const recipient = await prisma.recipient.create({
|
||||||
|
data: {
|
||||||
|
email: 'signer@example.com',
|
||||||
|
name: 'Test Signer',
|
||||||
|
role: RecipientRole.SIGNER,
|
||||||
|
token: 'test-token-3',
|
||||||
|
envelopeId: document.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add a FREE_SIGNATURE field
|
||||||
|
await prisma.field.create({
|
||||||
|
data: {
|
||||||
|
type: FieldType.FREE_SIGNATURE,
|
||||||
|
page: 1,
|
||||||
|
positionX: 100,
|
||||||
|
positionY: 100,
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
customText: '',
|
||||||
|
inserted: false,
|
||||||
|
recipientId: recipient.id,
|
||||||
|
envelopeId: document.id,
|
||||||
|
envelopeItemId: document.envelopeItems[0].id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { token } = await createApiToken({
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team.id,
|
||||||
|
tokenName: 'test',
|
||||||
|
expiresIn: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await request.post(
|
||||||
|
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v1/documents/${mapSecondaryIdToDocumentId(document.secondaryId)}/send`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
data: {},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.ok()).toBeTruthy();
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sendDocument: should succeed when non-signer roles have no fields', async ({ request }) => {
|
||||||
|
const { user, team } = await seedUser();
|
||||||
|
|
||||||
|
// Create a blank document and get it with envelope items
|
||||||
|
const blankDocument = await seedBlankDocument(user, team.id);
|
||||||
|
const document = await prisma.envelope.findUniqueOrThrow({
|
||||||
|
where: { id: blankDocument.id },
|
||||||
|
include: { envelopeItems: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add a signer with signature field
|
||||||
|
const signer = await prisma.recipient.create({
|
||||||
|
data: {
|
||||||
|
email: 'signer@example.com',
|
||||||
|
name: 'Test Signer',
|
||||||
|
role: RecipientRole.SIGNER,
|
||||||
|
token: 'test-token-4',
|
||||||
|
envelopeId: document.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.field.create({
|
||||||
|
data: {
|
||||||
|
type: FieldType.SIGNATURE,
|
||||||
|
page: 1,
|
||||||
|
positionX: 1,
|
||||||
|
positionY: 1,
|
||||||
|
width: 1,
|
||||||
|
height: 1,
|
||||||
|
customText: '',
|
||||||
|
inserted: false,
|
||||||
|
recipientId: signer.id,
|
||||||
|
envelopeId: document.id,
|
||||||
|
envelopeItemId: document.envelopeItems[0].id,
|
||||||
|
fieldMeta: { type: 'signature', fontSize: 14 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add a viewer without any fields
|
||||||
|
await prisma.recipient.create({
|
||||||
|
data: {
|
||||||
|
email: 'viewer@example.com',
|
||||||
|
name: 'Test Viewer',
|
||||||
|
role: RecipientRole.VIEWER,
|
||||||
|
token: 'test-token-5',
|
||||||
|
envelopeId: document.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add an approver without any fields
|
||||||
|
await prisma.recipient.create({
|
||||||
|
data: {
|
||||||
|
email: 'approver@example.com',
|
||||||
|
name: 'Test Approver',
|
||||||
|
role: RecipientRole.APPROVER,
|
||||||
|
token: 'test-token-6',
|
||||||
|
envelopeId: document.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add a CC without any fields
|
||||||
|
await prisma.recipient.create({
|
||||||
|
data: {
|
||||||
|
email: 'cc@example.com',
|
||||||
|
name: 'Test CC',
|
||||||
|
role: RecipientRole.CC,
|
||||||
|
token: 'test-token-7',
|
||||||
|
envelopeId: document.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { token } = await createApiToken({
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team.id,
|
||||||
|
tokenName: 'test',
|
||||||
|
expiresIn: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await request.post(
|
||||||
|
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v1/documents/${mapSecondaryIdToDocumentId(document.secondaryId)}/send`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
data: {},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.ok()).toBeTruthy();
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -171,6 +171,24 @@ test.describe('Template Field Prefill API v1', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Add SIGNATURE field (required for distribution)
|
||||||
|
await prisma.field.create({
|
||||||
|
data: {
|
||||||
|
envelopeId: template.id,
|
||||||
|
envelopeItemId: firstEnvelopeItem.id,
|
||||||
|
recipientId: recipient.id,
|
||||||
|
type: FieldType.SIGNATURE,
|
||||||
|
page: 1,
|
||||||
|
positionX: 1,
|
||||||
|
positionY: 1,
|
||||||
|
width: 1,
|
||||||
|
height: 1,
|
||||||
|
customText: '',
|
||||||
|
inserted: false,
|
||||||
|
fieldMeta: { type: 'signature', fontSize: 14 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// 6. Sign in as the user
|
// 6. Sign in as the user
|
||||||
await apiSignin({
|
await apiSignin({
|
||||||
page,
|
page,
|
||||||
@@ -444,6 +462,24 @@ test.describe('Template Field Prefill API v1', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Add SIGNATURE field (required for distribution)
|
||||||
|
await prisma.field.create({
|
||||||
|
data: {
|
||||||
|
envelopeId: template.id,
|
||||||
|
envelopeItemId: firstEnvelopeItem.id,
|
||||||
|
recipientId: recipient.id,
|
||||||
|
type: FieldType.SIGNATURE,
|
||||||
|
page: 1,
|
||||||
|
positionX: 1,
|
||||||
|
positionY: 1,
|
||||||
|
width: 1,
|
||||||
|
height: 1,
|
||||||
|
customText: '',
|
||||||
|
inserted: false,
|
||||||
|
fieldMeta: { type: 'signature', fontSize: 14 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// 6. Sign in as the user
|
// 6. Sign in as the user
|
||||||
await apiSignin({
|
await apiSignin({
|
||||||
page,
|
page,
|
||||||
|
|||||||
@@ -221,7 +221,7 @@ test.describe('Document Access API V1', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(resB.ok()).toBeFalsy();
|
expect(resB.ok()).toBeFalsy();
|
||||||
expect(resB.status()).toBe(500);
|
expect(resB.status()).toBe(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should allow authorized access to document send endpoint', async ({ request }) => {
|
test('should allow authorized access to document send endpoint', async ({ request }) => {
|
||||||
|
|||||||
@@ -0,0 +1,403 @@
|
|||||||
|
import { type APIRequestContext, expect, test } from '@playwright/test';
|
||||||
|
import type { Team, User } from '@prisma/client';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||||
|
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||||
|
import { EnvelopeType, FieldType, RecipientRole } from '@documenso/prisma/client';
|
||||||
|
import { seedUser } from '@documenso/prisma/seed/users';
|
||||||
|
import type {
|
||||||
|
TCreateEnvelopePayload,
|
||||||
|
TCreateEnvelopeResponse,
|
||||||
|
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
|
||||||
|
import type { TCreateEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/envelope-recipients/create-envelope-recipients.types';
|
||||||
|
import type { TGetEnvelopeResponse } from '@documenso/trpc/server/envelope-router/get-envelope.types';
|
||||||
|
|
||||||
|
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||||
|
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
|
||||||
|
|
||||||
|
test.describe.configure({
|
||||||
|
mode: 'parallel',
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Envelope distribute validation', () => {
|
||||||
|
let user: User, team: Team, token: string;
|
||||||
|
|
||||||
|
test.beforeEach(async () => {
|
||||||
|
({ user, team } = await seedUser());
|
||||||
|
({ token } = await createApiToken({
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team.id,
|
||||||
|
tokenName: 'test',
|
||||||
|
expiresIn: null,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
const createEnvelope = async (request: APIRequestContext, authToken: string) => {
|
||||||
|
const payload: TCreateEnvelopePayload = {
|
||||||
|
type: EnvelopeType.DOCUMENT,
|
||||||
|
title: 'Test Document',
|
||||||
|
};
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('payload', JSON.stringify(payload));
|
||||||
|
|
||||||
|
const pdfData = fs.readFileSync(path.join(__dirname, '../../../../../assets/example.pdf'));
|
||||||
|
formData.append('files', new File([pdfData], 'test.pdf', { type: 'application/pdf' }));
|
||||||
|
|
||||||
|
const res = await request.post(`${baseUrl}/envelope/create`, {
|
||||||
|
headers: { Authorization: `Bearer ${authToken}` },
|
||||||
|
multipart: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
return (await res.json()) as TCreateEnvelopeResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getEnvelope = async (request: APIRequestContext, authToken: string, envelopeId: string) => {
|
||||||
|
const res = await request.get(`${baseUrl}/envelope/${envelopeId}`, {
|
||||||
|
headers: { Authorization: `Bearer ${authToken}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
return (await res.json()) as TGetEnvelopeResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createRecipients = async (
|
||||||
|
request: APIRequestContext,
|
||||||
|
authToken: string,
|
||||||
|
envelopeId: string,
|
||||||
|
recipients: TCreateEnvelopeRecipientsRequest['data'],
|
||||||
|
) => {
|
||||||
|
const res = await request.post(`${baseUrl}/envelope/recipient/create-many`, {
|
||||||
|
headers: { Authorization: `Bearer ${authToken}` },
|
||||||
|
data: {
|
||||||
|
envelopeId,
|
||||||
|
data: recipients,
|
||||||
|
} satisfies TCreateEnvelopeRecipientsRequest,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
return (await res.json()).data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createFields = async (
|
||||||
|
request: APIRequestContext,
|
||||||
|
authToken: string,
|
||||||
|
envelopeId: string,
|
||||||
|
envelopeItemId: string,
|
||||||
|
fields: Array<{ recipientId: number; type: FieldType }>,
|
||||||
|
) => {
|
||||||
|
const res = await request.post(`${baseUrl}/envelope/field/create-many`, {
|
||||||
|
headers: { Authorization: `Bearer ${authToken}` },
|
||||||
|
data: {
|
||||||
|
envelopeId,
|
||||||
|
data: fields.map((field, index) => ({
|
||||||
|
recipientId: field.recipientId,
|
||||||
|
envelopeItemId,
|
||||||
|
type: field.type,
|
||||||
|
page: 1,
|
||||||
|
positionX: 10,
|
||||||
|
positionY: 10 + index * 10,
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
return (await res.json()).data;
|
||||||
|
};
|
||||||
|
|
||||||
|
test('should fail to distribute when signer has no fields', async ({ request }) => {
|
||||||
|
const envelope = await createEnvelope(request, token);
|
||||||
|
|
||||||
|
// Create a signer without any fields
|
||||||
|
await createRecipients(request, token, envelope.id, [
|
||||||
|
{
|
||||||
|
email: 'signer@example.com',
|
||||||
|
name: 'Test Signer',
|
||||||
|
role: RecipientRole.SIGNER,
|
||||||
|
accessAuth: [],
|
||||||
|
actionAuth: [],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Try to distribute without adding any fields
|
||||||
|
const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
data: { envelopeId: envelope.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(distributeRes.ok()).toBeFalsy();
|
||||||
|
expect(distributeRes.status()).toBe(400);
|
||||||
|
|
||||||
|
const errorResponse = await distributeRes.json();
|
||||||
|
expect(errorResponse.message).toContain('missing required fields');
|
||||||
|
expect(errorResponse.message).toContain('Signers must have at least one signature field');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should fail to distribute when signer has non-signature fields only', async ({
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const envelope = await createEnvelope(request, token);
|
||||||
|
const envelopeData = await getEnvelope(request, token, envelope.id);
|
||||||
|
|
||||||
|
// Create a signer
|
||||||
|
const recipients = await createRecipients(request, token, envelope.id, [
|
||||||
|
{
|
||||||
|
email: 'signer@example.com',
|
||||||
|
name: 'Test Signer',
|
||||||
|
role: RecipientRole.SIGNER,
|
||||||
|
accessAuth: [],
|
||||||
|
actionAuth: [],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Add only a TEXT field (not a signature field)
|
||||||
|
await createFields(request, token, envelope.id, envelopeData.envelopeItems[0].id, [
|
||||||
|
{ recipientId: recipients[0].id, type: FieldType.TEXT },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Try to distribute
|
||||||
|
const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
data: { envelopeId: envelope.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(distributeRes.ok()).toBeFalsy();
|
||||||
|
expect(distributeRes.status()).toBe(400);
|
||||||
|
|
||||||
|
const errorResponse = await distributeRes.json();
|
||||||
|
expect(errorResponse.message).toContain('missing required fields');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should succeed when signer has SIGNATURE field', async ({ request }) => {
|
||||||
|
const envelope = await createEnvelope(request, token);
|
||||||
|
const envelopeData = await getEnvelope(request, token, envelope.id);
|
||||||
|
|
||||||
|
// Create a signer
|
||||||
|
const recipients = await createRecipients(request, token, envelope.id, [
|
||||||
|
{
|
||||||
|
email: 'signer@example.com',
|
||||||
|
name: 'Test Signer',
|
||||||
|
role: RecipientRole.SIGNER,
|
||||||
|
accessAuth: [],
|
||||||
|
actionAuth: [],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Add a SIGNATURE field
|
||||||
|
await createFields(request, token, envelope.id, envelopeData.envelopeItems[0].id, [
|
||||||
|
{ recipientId: recipients[0].id, type: FieldType.SIGNATURE },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Distribute should succeed
|
||||||
|
const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
data: { envelopeId: envelope.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(distributeRes.ok()).toBeTruthy();
|
||||||
|
expect(distributeRes.status()).toBe(200);
|
||||||
|
|
||||||
|
const response = await distributeRes.json();
|
||||||
|
expect(response.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Note: FREE_SIGNATURE field type is not supported via the v2 API for field creation,
|
||||||
|
// so we only test with SIGNATURE fields here. The v1 tests cover FREE_SIGNATURE
|
||||||
|
// using direct Prisma creation.
|
||||||
|
|
||||||
|
test('should succeed when VIEWER has no fields', async ({ request }) => {
|
||||||
|
const envelope = await createEnvelope(request, token);
|
||||||
|
const envelopeData = await getEnvelope(request, token, envelope.id);
|
||||||
|
|
||||||
|
// Create a signer and a viewer
|
||||||
|
const recipients = await createRecipients(request, token, envelope.id, [
|
||||||
|
{
|
||||||
|
email: 'signer@example.com',
|
||||||
|
name: 'Test Signer',
|
||||||
|
role: RecipientRole.SIGNER,
|
||||||
|
accessAuth: [],
|
||||||
|
actionAuth: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
email: 'viewer@example.com',
|
||||||
|
name: 'Test Viewer',
|
||||||
|
role: RecipientRole.VIEWER,
|
||||||
|
accessAuth: [],
|
||||||
|
actionAuth: [],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Add signature field only for the signer (viewer has no fields)
|
||||||
|
await createFields(request, token, envelope.id, envelopeData.envelopeItems[0].id, [
|
||||||
|
{ recipientId: recipients[0].id, type: FieldType.SIGNATURE },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Distribute should succeed
|
||||||
|
const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
data: { envelopeId: envelope.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(distributeRes.ok()).toBeTruthy();
|
||||||
|
expect(distributeRes.status()).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should succeed when CC has no fields', async ({ request }) => {
|
||||||
|
const envelope = await createEnvelope(request, token);
|
||||||
|
const envelopeData = await getEnvelope(request, token, envelope.id);
|
||||||
|
|
||||||
|
// Create a signer and a CC recipient
|
||||||
|
const recipients = await createRecipients(request, token, envelope.id, [
|
||||||
|
{
|
||||||
|
email: 'signer@example.com',
|
||||||
|
name: 'Test Signer',
|
||||||
|
role: RecipientRole.SIGNER,
|
||||||
|
accessAuth: [],
|
||||||
|
actionAuth: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
email: 'cc@example.com',
|
||||||
|
name: 'Test CC',
|
||||||
|
role: RecipientRole.CC,
|
||||||
|
accessAuth: [],
|
||||||
|
actionAuth: [],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Add signature field only for the signer (CC has no fields)
|
||||||
|
await createFields(request, token, envelope.id, envelopeData.envelopeItems[0].id, [
|
||||||
|
{ recipientId: recipients[0].id, type: FieldType.SIGNATURE },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Distribute should succeed
|
||||||
|
const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
data: { envelopeId: envelope.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(distributeRes.ok()).toBeTruthy();
|
||||||
|
expect(distributeRes.status()).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should succeed when APPROVER has no fields', async ({ request }) => {
|
||||||
|
const envelope = await createEnvelope(request, token);
|
||||||
|
const envelopeData = await getEnvelope(request, token, envelope.id);
|
||||||
|
|
||||||
|
// Create a signer and an approver
|
||||||
|
const recipients = await createRecipients(request, token, envelope.id, [
|
||||||
|
{
|
||||||
|
email: 'signer@example.com',
|
||||||
|
name: 'Test Signer',
|
||||||
|
role: RecipientRole.SIGNER,
|
||||||
|
accessAuth: [],
|
||||||
|
actionAuth: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
email: 'approver@example.com',
|
||||||
|
name: 'Test Approver',
|
||||||
|
role: RecipientRole.APPROVER,
|
||||||
|
accessAuth: [],
|
||||||
|
actionAuth: [],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Add signature field only for the signer (approver has no fields)
|
||||||
|
await createFields(request, token, envelope.id, envelopeData.envelopeItems[0].id, [
|
||||||
|
{ recipientId: recipients[0].id, type: FieldType.SIGNATURE },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Distribute should succeed
|
||||||
|
const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
data: { envelopeId: envelope.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(distributeRes.ok()).toBeTruthy();
|
||||||
|
expect(distributeRes.status()).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should fail when one of multiple signers is missing signature field', async ({
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const envelope = await createEnvelope(request, token);
|
||||||
|
const envelopeData = await getEnvelope(request, token, envelope.id);
|
||||||
|
|
||||||
|
// Create two signers
|
||||||
|
const recipients = await createRecipients(request, token, envelope.id, [
|
||||||
|
{
|
||||||
|
email: 'signer1@example.com',
|
||||||
|
name: 'Test Signer 1',
|
||||||
|
role: RecipientRole.SIGNER,
|
||||||
|
accessAuth: [],
|
||||||
|
actionAuth: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
email: 'signer2@example.com',
|
||||||
|
name: 'Test Signer 2',
|
||||||
|
role: RecipientRole.SIGNER,
|
||||||
|
accessAuth: [],
|
||||||
|
actionAuth: [],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Add signature field only for the first signer
|
||||||
|
await createFields(request, token, envelope.id, envelopeData.envelopeItems[0].id, [
|
||||||
|
{ recipientId: recipients[0].id, type: FieldType.SIGNATURE },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Distribute should fail because second signer has no signature field
|
||||||
|
const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
data: { envelopeId: envelope.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(distributeRes.ok()).toBeFalsy();
|
||||||
|
expect(distributeRes.status()).toBe(400);
|
||||||
|
|
||||||
|
const errorResponse = await distributeRes.json();
|
||||||
|
expect(errorResponse.message).toContain('missing required fields');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should succeed when all signers have signature fields', async ({ request }) => {
|
||||||
|
const envelope = await createEnvelope(request, token);
|
||||||
|
const envelopeData = await getEnvelope(request, token, envelope.id);
|
||||||
|
|
||||||
|
// Create two signers
|
||||||
|
const recipients = await createRecipients(request, token, envelope.id, [
|
||||||
|
{
|
||||||
|
email: 'signer1@example.com',
|
||||||
|
name: 'Test Signer 1',
|
||||||
|
role: RecipientRole.SIGNER,
|
||||||
|
accessAuth: [],
|
||||||
|
actionAuth: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
email: 'signer2@example.com',
|
||||||
|
name: 'Test Signer 2',
|
||||||
|
role: RecipientRole.SIGNER,
|
||||||
|
accessAuth: [],
|
||||||
|
actionAuth: [],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Add signature fields for both signers
|
||||||
|
await createFields(request, token, envelope.id, envelopeData.envelopeItems[0].id, [
|
||||||
|
{ recipientId: recipients[0].id, type: FieldType.SIGNATURE },
|
||||||
|
{ recipientId: recipients[1].id, type: FieldType.SIGNATURE },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Distribute should succeed
|
||||||
|
const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
data: { envelopeId: envelope.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(distributeRes.ok()).toBeTruthy();
|
||||||
|
expect(distributeRes.status()).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -171,6 +171,24 @@ test.describe('Template Field Prefill API v2', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Add SIGNATURE field (required for distribution)
|
||||||
|
await prisma.field.create({
|
||||||
|
data: {
|
||||||
|
envelopeId: template.id,
|
||||||
|
envelopeItemId: firstEnvelopeItem.id,
|
||||||
|
recipientId: recipient.id,
|
||||||
|
type: FieldType.SIGNATURE,
|
||||||
|
page: 1,
|
||||||
|
positionX: 1,
|
||||||
|
positionY: 1,
|
||||||
|
width: 1,
|
||||||
|
height: 1,
|
||||||
|
customText: '',
|
||||||
|
inserted: false,
|
||||||
|
fieldMeta: { type: 'signature', fontSize: 14 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// 6. Sign in as the user
|
// 6. Sign in as the user
|
||||||
await apiSignin({
|
await apiSignin({
|
||||||
page,
|
page,
|
||||||
@@ -441,6 +459,24 @@ test.describe('Template Field Prefill API v2', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Add SIGNATURE field (required for distribution)
|
||||||
|
await prisma.field.create({
|
||||||
|
data: {
|
||||||
|
envelopeId: template.id,
|
||||||
|
envelopeItemId: firstEnvelopeItem.id,
|
||||||
|
recipientId: recipient.id,
|
||||||
|
type: FieldType.SIGNATURE,
|
||||||
|
page: 1,
|
||||||
|
positionX: 1,
|
||||||
|
positionY: 1,
|
||||||
|
width: 1,
|
||||||
|
height: 1,
|
||||||
|
customText: '',
|
||||||
|
inserted: false,
|
||||||
|
fieldMeta: { type: 'signature', fontSize: 14 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// 6. Sign in as the user
|
// 6. Sign in as the user
|
||||||
await apiSignin({
|
await apiSignin({
|
||||||
page,
|
page,
|
||||||
|
|||||||
@@ -195,6 +195,31 @@ test.describe('Document API V2', () => {
|
|||||||
}) => {
|
}) => {
|
||||||
const doc = await seedDraftDocument(userA, teamA.id, ['test@example.com']);
|
const doc = await seedDraftDocument(userA, teamA.id, ['test@example.com']);
|
||||||
|
|
||||||
|
// Get the recipient created during seeding.
|
||||||
|
const recipient = await prisma.recipient.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
envelopeId: doc.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a signature field for the recipient so distribution validation can run.
|
||||||
|
await prisma.field.create({
|
||||||
|
data: {
|
||||||
|
envelopeId: doc.id,
|
||||||
|
envelopeItemId: doc.envelopeItems[0].id,
|
||||||
|
recipientId: recipient.id,
|
||||||
|
type: FieldType.SIGNATURE,
|
||||||
|
page: 1,
|
||||||
|
positionX: 1,
|
||||||
|
positionY: 1,
|
||||||
|
width: 1,
|
||||||
|
height: 1,
|
||||||
|
customText: '',
|
||||||
|
inserted: false,
|
||||||
|
fieldMeta: { type: 'signature', fontSize: 14 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/document/distribute`, {
|
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/document/distribute`, {
|
||||||
headers: { Authorization: `Bearer ${tokenB}` },
|
headers: { Authorization: `Bearer ${tokenB}` },
|
||||||
data: { documentId: mapSecondaryIdToDocumentId(doc.secondaryId) },
|
data: { documentId: mapSecondaryIdToDocumentId(doc.secondaryId) },
|
||||||
@@ -207,6 +232,31 @@ test.describe('Document API V2', () => {
|
|||||||
test('should allow authorized access to document distribute endpoint', async ({ request }) => {
|
test('should allow authorized access to document distribute endpoint', async ({ request }) => {
|
||||||
const doc = await seedDraftDocument(userA, teamA.id, ['test@example.com']);
|
const doc = await seedDraftDocument(userA, teamA.id, ['test@example.com']);
|
||||||
|
|
||||||
|
// Get the recipient created during seeding.
|
||||||
|
const recipient = await prisma.recipient.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
envelopeId: doc.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a signature field for the recipient so distribution validation can run.
|
||||||
|
await prisma.field.create({
|
||||||
|
data: {
|
||||||
|
envelopeId: doc.id,
|
||||||
|
envelopeItemId: doc.envelopeItems[0].id,
|
||||||
|
recipientId: recipient.id,
|
||||||
|
type: FieldType.SIGNATURE,
|
||||||
|
page: 1,
|
||||||
|
positionX: 1,
|
||||||
|
positionY: 1,
|
||||||
|
width: 1,
|
||||||
|
height: 1,
|
||||||
|
customText: '',
|
||||||
|
inserted: false,
|
||||||
|
fieldMeta: { type: 'signature', fontSize: 14 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/document/distribute`, {
|
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/document/distribute`, {
|
||||||
headers: { Authorization: `Bearer ${tokenA}` },
|
headers: { Authorization: `Bearer ${tokenA}` },
|
||||||
data: { documentId: mapSecondaryIdToDocumentId(doc.secondaryId) },
|
data: { documentId: mapSecondaryIdToDocumentId(doc.secondaryId) },
|
||||||
@@ -3678,6 +3728,26 @@ test.describe('Document API V2', () => {
|
|||||||
internalVersion: 2,
|
internalVersion: 2,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [recipient] = doc.recipients;
|
||||||
|
|
||||||
|
// add signing field for recipient (fieldMeta required for v2 envelopes)
|
||||||
|
await prisma.field.create({
|
||||||
|
data: {
|
||||||
|
page: 1,
|
||||||
|
type: FieldType.SIGNATURE,
|
||||||
|
inserted: false,
|
||||||
|
customText: '',
|
||||||
|
positionX: 1,
|
||||||
|
positionY: 1,
|
||||||
|
width: 1,
|
||||||
|
height: 1,
|
||||||
|
envelopeId: doc.id,
|
||||||
|
envelopeItemId: doc.envelopeItems[0].id,
|
||||||
|
recipientId: recipient.id,
|
||||||
|
fieldMeta: { type: 'signature', fontSize: 14 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const payload: TUseEnvelopePayload = {
|
const payload: TUseEnvelopePayload = {
|
||||||
envelopeId: doc.id,
|
envelopeId: doc.id,
|
||||||
distributeDocument: true,
|
distributeDocument: true,
|
||||||
@@ -3741,6 +3811,31 @@ test.describe('Document API V2', () => {
|
|||||||
}) => {
|
}) => {
|
||||||
const doc = await seedDraftDocument(userA, teamA.id, ['test@example.com']);
|
const doc = await seedDraftDocument(userA, teamA.id, ['test@example.com']);
|
||||||
|
|
||||||
|
// Get the recipient created during seeding.
|
||||||
|
const recipient = await prisma.recipient.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
envelopeId: doc.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a signature field for the recipient so distribution validation can pass.
|
||||||
|
await prisma.field.create({
|
||||||
|
data: {
|
||||||
|
envelopeId: doc.id,
|
||||||
|
envelopeItemId: doc.envelopeItems[0].id,
|
||||||
|
recipientId: recipient.id,
|
||||||
|
type: FieldType.SIGNATURE,
|
||||||
|
page: 1,
|
||||||
|
positionX: 1,
|
||||||
|
positionY: 1,
|
||||||
|
width: 1,
|
||||||
|
height: 1,
|
||||||
|
customText: '',
|
||||||
|
inserted: false,
|
||||||
|
fieldMeta: { type: 'signature', fontSize: 14 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/distribute`, {
|
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/distribute`, {
|
||||||
headers: { Authorization: `Bearer ${tokenA}` },
|
headers: { Authorization: `Bearer ${tokenA}` },
|
||||||
data: { envelopeId: doc.id },
|
data: { envelopeId: doc.id },
|
||||||
|
|||||||
@@ -257,10 +257,12 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
|
|||||||
|
|
||||||
// Change second recipient role if role selector is available
|
// Change second recipient role if role selector is available
|
||||||
const roleDropdown = page.getByLabel('Role').nth(1);
|
const roleDropdown = page.getByLabel('Role').nth(1);
|
||||||
|
let secondRecipientIsApprover = false;
|
||||||
|
|
||||||
if (await roleDropdown.isVisible()) {
|
if (await roleDropdown.isVisible()) {
|
||||||
await roleDropdown.click();
|
await roleDropdown.click();
|
||||||
await page.getByText('Approver').click();
|
await page.getByText('Approver').click();
|
||||||
|
secondRecipientIsApprover = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Add different field types for each duplicate
|
// Step 3: Add different field types for each duplicate
|
||||||
@@ -281,6 +283,13 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
|
|||||||
await page.getByRole('button', { name: 'Date' }).click();
|
await page.getByRole('button', { name: 'Date' }).click();
|
||||||
await page.locator('canvas').click({ position: { x: 200, y: 150 } });
|
await page.locator('canvas').click({ position: { x: 200, y: 150 } });
|
||||||
|
|
||||||
|
// If second recipient is still a SIGNER (role change wasn't available),
|
||||||
|
// add a signature field for them to pass validation
|
||||||
|
if (!secondRecipientIsApprover) {
|
||||||
|
await page.getByRole('button', { name: 'Signature' }).click();
|
||||||
|
await page.locator('canvas').click({ position: { x: 200, y: 200 } });
|
||||||
|
}
|
||||||
|
|
||||||
// Complete the document
|
// Complete the document
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
|
||||||
|
|||||||
@@ -50,11 +50,13 @@ const completeTemplateFlowWithDuplicateRecipients = async (options: {
|
|||||||
await page.getByRole('button', { name: 'Signature' }).click();
|
await page.getByRole('button', { name: 'Signature' }).click();
|
||||||
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
|
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
|
||||||
|
|
||||||
// Switch to different recipient and add their field
|
// Switch to different recipient and add their fields
|
||||||
await page.getByRole('combobox').first().click();
|
await page.getByRole('combobox').first().click();
|
||||||
await page.getByText('Different Recipient').first().click();
|
await page.getByText('Different Recipient').first().click();
|
||||||
await page.getByRole('button', { name: 'Name' }).click();
|
await page.getByRole('button', { name: 'Signature' }).click();
|
||||||
await page.locator('canvas').click({ position: { x: 300, y: 100 } });
|
await page.locator('canvas').click({ position: { x: 300, y: 100 } });
|
||||||
|
await page.getByRole('button', { name: 'Name' }).click();
|
||||||
|
await page.locator('canvas').click({ position: { x: 300, y: 150 } });
|
||||||
|
|
||||||
// Save template
|
// Save template
|
||||||
await page.getByRole('button', { name: 'Save Template' }).click();
|
await page.getByRole('button', { name: 'Save Template' }).click();
|
||||||
|
|||||||
@@ -38,7 +38,10 @@ 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 { 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';
|
||||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||||
@@ -148,30 +151,19 @@ export const sendDocument = async ({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Commented out server side checks for minimum 1 signature per signer now since we need to
|
// Validate that recipients who require fields (e.g., signers need signature fields) have them.
|
||||||
// decide if we want to enforce this for API & templates.
|
const recipientsWithMissingFields = getRecipientsWithMissingFields(
|
||||||
// const fields = await getFieldsForDocument({
|
envelope.recipients,
|
||||||
// documentId: documentId,
|
envelope.fields,
|
||||||
// userId: userId,
|
);
|
||||||
// });
|
|
||||||
|
|
||||||
// const fieldsWithSignerEmail = fields.map((field) => ({
|
if (recipientsWithMissingFields.length > 0) {
|
||||||
// ...field,
|
const missingRecipientIds = recipientsWithMissingFields.map((r) => r.id).join(', ');
|
||||||
// signerEmail:
|
|
||||||
// envelope.Recipient.find((recipient) => recipient.id === field.recipientId)?.email ?? '',
|
|
||||||
// }));
|
|
||||||
|
|
||||||
// const everySignerHasSignature = document?.Recipient.every(
|
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||||
// (recipient) =>
|
message: `The following recipients are missing required fields: ${missingRecipientIds}. Signers must have at least one signature field.`,
|
||||||
// recipient.role !== RecipientRole.SIGNER ||
|
});
|
||||||
// fieldsWithSignerEmail.some(
|
}
|
||||||
// (field) => field.type === 'SIGNATURE' && field.signerEmail === recipient.email,
|
|
||||||
// ),
|
|
||||||
// );
|
|
||||||
|
|
||||||
// if (!everySignerHasSignature) {
|
|
||||||
// throw new Error('Some signers have not been assigned a signature field.');
|
|
||||||
// }
|
|
||||||
|
|
||||||
const allRecipientsHaveNoActionToTake = envelope.recipients.every(
|
const allRecipientsHaveNoActionToTake = envelope.recipients.every(
|
||||||
(recipient) =>
|
(recipient) =>
|
||||||
|
|||||||
@@ -2,9 +2,40 @@ import type { Envelope } from '@prisma/client';
|
|||||||
import { type Field, type Recipient, RecipientRole, SigningStatus } from '@prisma/client';
|
import { type Field, type Recipient, RecipientRole, SigningStatus } from '@prisma/client';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
|
||||||
|
|
||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
|
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
|
||||||
import { extractLegacyIds } from '../universal/id';
|
import { extractLegacyIds } from '../universal/id';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Roles that require fields to be assigned before a document can be distributed.
|
||||||
|
*
|
||||||
|
* Currently only SIGNER requires a signature field.
|
||||||
|
*/
|
||||||
|
export const RECIPIENT_ROLES_THAT_REQUIRE_FIELDS = [RecipientRole.SIGNER] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns recipients who are missing required fields for their role.
|
||||||
|
*
|
||||||
|
* Currently only SIGNERs are validated - they must have at least one signature field.
|
||||||
|
*/
|
||||||
|
export const getRecipientsWithMissingFields = <T extends Pick<Recipient, 'id' | 'role'>>(
|
||||||
|
recipients: T[],
|
||||||
|
fields: Pick<Field, 'type' | 'recipientId'>[],
|
||||||
|
): T[] => {
|
||||||
|
return recipients.filter((recipient) => {
|
||||||
|
if (recipient.role === RecipientRole.SIGNER) {
|
||||||
|
const hasSignatureField = fields.some(
|
||||||
|
(field) => field.recipientId === recipient.id && isSignatureFieldType(field.type),
|
||||||
|
);
|
||||||
|
|
||||||
|
return !hasSignatureField;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const formatSigningLink = (token: string) => `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${token}`;
|
export const formatSigningLink = (token: string) => `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${token}`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import { parseMessageDescriptor } from '@documenso/lib/utils/i18n';
|
|||||||
import {
|
import {
|
||||||
canRecipientBeModified,
|
canRecipientBeModified,
|
||||||
canRecipientFieldsBeModified,
|
canRecipientFieldsBeModified,
|
||||||
|
getRecipientsWithMissingFields,
|
||||||
} from '@documenso/lib/utils/recipients';
|
} from '@documenso/lib/utils/recipients';
|
||||||
|
|
||||||
import { FieldToolTip } from '../../components/field/field-tooltip';
|
import { FieldToolTip } from '../../components/field/field-tooltip';
|
||||||
@@ -555,15 +556,11 @@ export const AddFieldsFormPartial = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleGoNextClick = () => {
|
const handleGoNextClick = () => {
|
||||||
const everySignerHasSignature = recipientsByRole.SIGNER.every((signer) =>
|
// localFields already have recipientId set correctly (see field creation at line 338)
|
||||||
localFields.some(
|
// Using the existing recipientId is important for handling duplicate email recipients
|
||||||
(field) =>
|
const recipientsMissingFields = getRecipientsWithMissingFields(recipients, localFields);
|
||||||
(field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE) &&
|
|
||||||
field.signerEmail === signer.email,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!everySignerHasSignature) {
|
if (recipientsMissingFields.length > 0) {
|
||||||
setIsMissingSignatureDialogVisible(true);
|
setIsMissingSignatureDialogVisible(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user