From b20a726571d866ec3db07a2afe4f24728bfcabe2 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 26 Aug 2026 16:01:30 +1000 Subject: [PATCH] fix: reviewed --- .../direct-template-signing-form.tsx | 26 +- .../document-signing-complete-dialog.tsx | 6 +- .../document-signing-form.tsx | 26 +- .../document-signing-page-view-v1.tsx | 37 +- .../envelope-signing-provider.tsx | 27 +- .../documents.$id.legacy_editor.tsx | 4 + .../templates.$id.legacy_editor.tsx | 4 + .../app-tests/e2e/fixtures/envelope-editor.ts | 336 +++++++++++++++++- .../server-only/envelope/create-envelope.ts | 42 ++- .../recipient/create-envelope-recipients.ts | 8 + .../recipient/set-document-recipients.ts | 6 + .../recipient/set-template-recipients.ts | 6 + .../recipient/update-envelope-recipients.ts | 11 + ...assign-default-recipient-signing-orders.ts | 57 +++ packages/lib/types/recipient.ts | 11 + packages/lib/utils/recipient-groups.test.ts | 16 +- .../create-embedding-document.types.ts | 3 +- .../create-embedding-template.types.ts | 4 +- .../update-embedding-document.types.ts | 3 +- .../update-embedding-template.types.ts | 4 +- .../create-envelope-recipients.types.ts | 8 +- .../set-envelope-recipients.types.ts | 18 +- .../trpc/server/recipient-router/schema.ts | 10 +- 23 files changed, 543 insertions(+), 130 deletions(-) create mode 100644 packages/lib/server-only/signature-level/assign-default-recipient-signing-orders.ts diff --git a/apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx b/apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx index 179d3306a..4fafb4b50 100644 --- a/apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx +++ b/apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx @@ -11,6 +11,7 @@ import { import type { TTemplate } from '@documenso/lib/types/template'; import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers'; import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields'; +import { getNextDictatableRecipient } from '@documenso/lib/utils/recipient-groups'; import type { TRemovedSignedFieldWithTokenMutationSchema, TSignFieldWithTokenMutationSchema, @@ -223,27 +224,10 @@ export const DirectTemplateSigningForm = ({ return undefined; } - const sortedRecipients = template.recipients.sort((a, b) => { - // Sort by signingOrder first (nulls last), then by id - if (a.signingOrder === null && b.signingOrder === null) { - return a.id - b.id; - } - if (a.signingOrder === null) { - return 1; - } - if (b.signingOrder === null) { - return -1; - } - if (a.signingOrder === b.signingOrder) { - return a.id - b.id; - } - return a.signingOrder - b.signingOrder; + return getNextDictatableRecipient({ + recipients: template.recipients, + currentRecipientId: directRecipient.id, }); - - const currentIndex = sortedRecipients.findIndex((r) => r.id === directRecipient.id); - return currentIndex !== -1 && currentIndex < sortedRecipients.length - 1 - ? sortedRecipients[currentIndex + 1] - : undefined; }, [template.templateMeta?.signingOrder, template.recipients, directRecipient.id]); return ( @@ -435,7 +419,7 @@ export const DirectTemplateSigningForm = ({ fields={localFields} fieldsValidated={fieldsValidated} recipient={directRecipient} - allowDictateNextSigner={nextRecipient && template.templateMeta?.allowDictateNextSigner} + allowDictateNextSigner={Boolean(nextRecipient && template.templateMeta?.allowDictateNextSigner)} defaultNextSigner={nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined} /> diff --git a/apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx b/apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx index 41abb63f1..afe1b067a 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx @@ -100,8 +100,10 @@ export const DocumentSigningCompleteDialog = ({ const { isNameLocked, isEmailLocked } = useEmbedSigningContext() || {}; + const canDictateNextSigner = allowDictateNextSigner && Boolean(defaultNextSigner); + const form = useForm({ - resolver: allowDictateNextSigner ? zodResolver(ZNextSignerFormSchema) : undefined, + resolver: canDictateNextSigner ? zodResolver(ZNextSignerFormSchema) : undefined, defaultValues: { name: defaultNextSigner?.name ?? '', email: defaultNextSigner?.email ?? '', @@ -317,7 +319,7 @@ export const DocumentSigningCompleteDialog = ({
- {allowDictateNextSigner && defaultNextSigner && ( + {canDictateNextSigner && (
Promise; isSubmitting: boolean; fieldsValidated: () => void; - nextRecipient?: RecipientWithFields; + /** + * The dictatable next recipient, decided server-side. Only their identity + * is needed — for the dictation flag and the prefilled inputs. + */ + nextRecipient?: Pick; }; export const DocumentSigningForm = ({ @@ -84,6 +88,10 @@ export const DocumentSigningForm = ({ return fieldsRequiringValidation.filter((field) => field.recipientId === recipient.id); }, [fieldsRequiringValidation, recipient]); + const allowDictateNextSigner = Boolean(nextRecipient && document.documentMeta?.allowDictateNextSigner); + + const defaultNextSigner = nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined; + const localFieldsValidated = () => { setValidateUninsertedFields(true); fieldsValidated(); @@ -151,10 +159,8 @@ export const DocumentSigningForm = ({ completeDocument({ nextSigner, accessAuthOptions }) } recipient={recipient} - allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner} - defaultNextSigner={ - nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined - } + allowDictateNextSigner={allowDictateNextSigner} + defaultNextSigner={defaultNextSigner} />
@@ -223,8 +229,8 @@ export const DocumentSigningForm = ({ onClose={() => !isAssistantSubmitting && setIsConfirmationDialogOpen(false)} onConfirm={handleAssistantConfirmDialogSubmit} isSubmitting={isAssistantSubmitting} - allowDictateNextSigner={nextRecipient && document.documentMeta?.allowDictateNextSigner} - defaultNextSigner={nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined} + allowDictateNextSigner={allowDictateNextSigner} + defaultNextSigner={defaultNextSigner} />
) : ( @@ -291,10 +297,8 @@ export const DocumentSigningForm = ({ }) } recipient={recipient} - allowDictateNextSigner={nextRecipient && document.documentMeta?.allowDictateNextSigner} - defaultNextSigner={ - nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined - } + allowDictateNextSigner={allowDictateNextSigner} + defaultNextSigner={defaultNextSigner} /> diff --git a/apps/remix/app/components/general/document-signing/document-signing-page-view-v1.tsx b/apps/remix/app/components/general/document-signing/document-signing-page-view-v1.tsx index 1979c63a2..b4bee8d6e 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-page-view-v1.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-page-view-v1.tsx @@ -23,7 +23,7 @@ import { Button } from '@documenso/ui/primitives/button'; import { Card, CardContent } from '@documenso/ui/primitives/card'; import { ElementVisible } from '@documenso/ui/primitives/element-visible'; import { Trans } from '@lingui/react/macro'; -import type { Field } from '@prisma/client'; +import type { Field, Recipient } from '@prisma/client'; import { FieldType, RecipientRole } from '@prisma/client'; import { LucideChevronDown, LucideChevronUp } from 'lucide-react'; import { useMemo, useState } from 'react'; @@ -61,6 +61,12 @@ export type DocumentSigningPageViewV1Props = { completedFields: CompletedField[]; isRecipientsTurn: boolean; allRecipients?: RecipientWithFields[]; + /** + * The dictatable next recipient, computed server-side over the FULL + * recipient list — must not be re-derived from the role-scoped + * `allRecipients`. + */ + nextRecipient?: Pick; branding: DocumentSigningBranding; includeSenderDetails: boolean; }; @@ -72,6 +78,7 @@ export const DocumentSigningPageViewV1 = ({ completedFields, isRecipientsTurn, allRecipients = [], + nextRecipient, includeSenderDetails, branding, }: DocumentSigningPageViewV1Props) => { @@ -142,34 +149,6 @@ export const DocumentSigningPageViewV1 = ({ const selectedSigner = allRecipients?.find((r) => r.id === selectedSignerId); const targetSigner = recipient.role === RecipientRole.ASSISTANT && selectedSigner ? selectedSigner : null; - const nextRecipient = useMemo(() => { - if (!documentMeta?.signingOrder || documentMeta.signingOrder !== 'SEQUENTIAL') { - return undefined; - } - - const sortedRecipients = [...allRecipients].sort((a, b) => { - // Sort by signingOrder first (nulls last), then by id - if (a.signingOrder === null && b.signingOrder === null) { - return a.id - b.id; - } - if (a.signingOrder === null) { - return 1; - } - if (b.signingOrder === null) { - return -1; - } - if (a.signingOrder === b.signingOrder) { - return a.id - b.id; - } - return a.signingOrder - b.signingOrder; - }); - - const currentIndex = sortedRecipients.findIndex((r) => r.id === recipient.id); - return currentIndex !== -1 && currentIndex < sortedRecipients.length - 1 - ? sortedRecipients[currentIndex + 1] - : undefined; - }, [document.documentMeta?.signingOrder, allRecipients, recipient.id]); - const pendingFields = fieldsRequiringValidation.filter((field) => !field.inserted); const hasPendingFields = pendingFields.length > 0; diff --git a/apps/remix/app/components/general/document-signing/envelope-signing-provider.tsx b/apps/remix/app/components/general/document-signing/envelope-signing-provider.tsx index f0b00fec9..760cfdcaf 100644 --- a/apps/remix/app/components/general/document-signing/envelope-signing-provider.tsx +++ b/apps/remix/app/components/general/document-signing/envelope-signing-provider.tsx @@ -6,6 +6,7 @@ import type { EnvelopeForSigningResponse } from '@documenso/lib/server-only/enve import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import { isFieldUnsignedAndRequired, isRequiredField } from '@documenso/lib/utils/advanced-fields-helpers'; import { extractFieldInsertionValues } from '@documenso/lib/utils/envelope-signing'; +import { getNextDictatableRecipient } from '@documenso/lib/utils/recipient-groups'; import { trpc } from '@documenso/trpc/react'; import type { TSignEnvelopeFieldValue } from '@documenso/trpc/server/envelope-router/sign-envelope-field.types'; import { EnvelopeType, type Field, FieldType, type Recipient, RecipientRole, SigningStatus } from '@prisma/client'; @@ -290,32 +291,14 @@ export const EnvelopeSigningProvider = ({ .filter((field) => field.inserted); const nextRecipient = useMemo(() => { - if (!envelope.documentMeta.signingOrder || envelope.documentMeta.signingOrder !== 'SEQUENTIAL') { + if (envelope.documentMeta.signingOrder !== 'SEQUENTIAL') { return null; } - const sortedRecipients = [...envelope.recipients].sort((a, b) => { - // Sort by signingOrder first (nulls last), then by id - if (a.signingOrder === null && b.signingOrder === null) { - return a.id - b.id; - } - if (a.signingOrder === null) { - return 1; - } - if (b.signingOrder === null) { - return -1; - } - if (a.signingOrder === b.signingOrder) { - return a.id - b.id; - } - return a.signingOrder - b.signingOrder; + return getNextDictatableRecipient({ + recipients: envelope.recipients, + currentRecipientId: recipient.id, }); - - const currentIndex = sortedRecipients.findIndex((r) => r.id === recipient.id); - - return currentIndex !== -1 && currentIndex < sortedRecipients.length - 1 - ? sortedRecipients[currentIndex + 1] - : null; }, [envelope.documentMeta?.signingOrder, envelope.recipients, recipient.id]); const signField = async ( diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.legacy_editor.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.legacy_editor.tsx index 7baf38f9b..bf2c757c8 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.legacy_editor.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.legacy_editor.tsx @@ -43,6 +43,10 @@ export async function loader({ params, request }: Route.LoaderArgs) { throw new Response('Not Found', { status: 404 }); } + if (document.internalVersion !== 1) { + throw redirect(`${documentRootPath}/${document.envelopeId}/edit`); + } + const documentVisibility = document.visibility; const currentTeamMemberRole = team.currentTeamRole; const isRecipient = document.recipients.find((recipient) => recipient.email === user.email); diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id.legacy_editor.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id.legacy_editor.tsx index 6e8607cc7..87737ee0e 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id.legacy_editor.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id.legacy_editor.tsx @@ -42,6 +42,10 @@ export async function loader({ params, request }: Route.LoaderArgs) { throw redirect(templateRootPath); } + if (template.internalVersion !== 1) { + throw redirect(`${templateRootPath}/${template.envelopeId}/edit`); + } + return superLoaderJson({ template: { ...template, diff --git a/packages/app-tests/e2e/fixtures/envelope-editor.ts b/packages/app-tests/e2e/fixtures/envelope-editor.ts index b49304b04..fb1d4b972 100644 --- a/packages/app-tests/e2e/fixtures/envelope-editor.ts +++ b/packages/app-tests/e2e/fixtures/envelope-editor.ts @@ -6,7 +6,7 @@ import { DEFAULT_EMBEDDED_EDITOR_CONFIG } from '@documenso/lib/types/envelope-ed import { seedBlankDocument } from '@documenso/prisma/seed/documents'; import { seedBlankTemplate } from '@documenso/prisma/seed/templates'; 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 { 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 getSigningOrderInputs = (root: Page) => root.locator('[data-testid="signing-order-input"]'); - export const clickEnvelopeEditorStep = async (root: Page, stepId: 'upload' | 'addFields' | 'preview') => { await root.waitForTimeout(200); await root.locator(`[data-testid="envelope-editor-step-${stepId}"]`).first().click(); @@ -335,10 +333,334 @@ export const toggleAllowDictateSigners = async (root: Page, enabled: boolean) => } }; -export const setSigningOrderValue = async (root: Page, index: number, value: number) => { - const input = getSigningOrderInputs(root).nth(index); - await input.fill(value.toString()); - await input.blur(); +/** + * Performs a mouse-based drag from a drag handle onto a target element. + * + * `@hello-pangea/dnd` only starts a drag once the pointer travels a small + * distance while pressed, and it hit-tests drop targets using the CENTRE of + * the dragged element — not the cursor. Since drag handles sit at the edge of + * wide rows/cards, the cursor destination is compensated so the dragged + * element's centre lands on the target's centre. + */ +export const dragHandleToTarget = async ( + root: Page, + handle: Locator, + target: Locator, + options: { activeClass: string }, +) => { + const { activeClass } = options; + + await handle.scrollIntoViewIfNeeded(); + + const handleBox = await handle.boundingBox(); + + if (!handleBox) { + throw new Error('Unable to resolve drag handle position'); + } + + const startX = handleBox.x + handleBox.width / 2; + const startY = handleBox.y + handleBox.height / 2; + + await root.mouse.move(startX, startY); + await root.mouse.down(); + + // Exceed the drag activation threshold, then wait for drag-dependent layout + // (e.g. expanding gap drop-zones) to settle before resolving positions. + const cursorX = startX + 8; + const cursorY = startY; + + await root.mouse.move(cursorX, cursorY, { steps: 2 }); + await root.waitForTimeout(300); + + // The dragged element is the handle's draggable ancestor; while dragging it + // is fixed-positioned and follows the cursor at a constant offset. Drop + // targeting uses the dragged element's CENTRE, not the cursor, so the + // cursor destination is compensated by that offset. + const draggedElement = handle.locator('xpath=ancestor-or-self::*[@data-rfd-draggable-id][1]'); + const draggedBox = await draggedElement.boundingBox(); + const targetBox = await target.boundingBox(); + + if (!draggedBox || !targetBox) { + await root.mouse.up(); + + throw new Error('Unable to resolve drag positions'); + } + + const itemOffsetX = draggedBox.x + draggedBox.width / 2 - cursorX; + const itemOffsetY = draggedBox.y + draggedBox.height / 2 - cursorY; + + const hasBecomeActive = async () => { + const className = await target.getAttribute('class'); + + return Boolean(className?.includes(activeClass)); + }; + + // The highlight class is rendered from the library's own drag state, so it + // cannot disagree with where a drop will land — both phases below only drop + // once the target reports the drag as over it AND that state survives a + // short confirmation dwell (it can flicker while crossing a card's + // reorder/combine boundary). + // + // The cursor is always clamped inside the viewport: moving outside the + // window cancels the drag (pointercancel), and holding near the bottom edge + // lets the library auto-scroll the target up to the cursor instead. + const viewportHeight = root.viewportSize()?.height ?? 720; + const maxCursorY = viewportHeight - 40; + + const confirmAndDrop = async () => { + if (!(await hasBecomeActive())) { + return false; + } + + await root.waitForTimeout(150); + + if (!(await hasBecomeActive())) { + return false; + } + + await root.mouse.up(); + + return true; + }; + + let hasDropped = false; + + // Crawl-and-drop: approach from above and inch downward through the + // corridor. Captured drop-target geometry can drift a few pixels from the + // live layout for small targets, so a slow traversal is the reliable way to + // hit them. + const crawlX = targetBox.x + targetBox.width / 2 - itemOffsetX; + const crawlStartY = Math.min(targetBox.y + targetBox.height / 2 - itemOffsetY - 140, maxCursorY); + + await root.mouse.move(crawlX, crawlStartY, { steps: 15 }); + await root.waitForTimeout(150); + + for (let step = 1; step <= 80; step += 1) { + if (await confirmAndDrop()) { + hasDropped = true; + + break; + } + + await root.mouse.move(crawlX, Math.min(crawlStartY + step * 6, maxCursorY), { steps: 2 }); + await root.waitForTimeout(70); + } + + if (!hasDropped) { + await root.mouse.up(); + } + + await root.waitForTimeout(400); +}; + +export const getRecipientStepCards = (root: Page) => root.locator('[data-testid="recipient-step-card"]'); + +export const getRecipientStepGaps = (root: Page) => root.locator('[data-testid="recipient-step-gap"]'); + +export const getStepDragHandles = (root: Page) => root.locator('[data-testid="step-drag-handle"]'); + +export const getRecipientRowDragHandles = (root: Page) => root.locator('[data-testid="recipient-row-drag-handle"]'); + +/** + * Drags a whole group card onto another card, merging the two groups. + * + * Uses @hello-pangea/dnd's keyboard drag mode: mouse-emulated combines are + * unreliable because approaching a card traverses its reorder edge, which + * displaces the target away from the cursor. Keyboard drags step through + * positions (including combine states) deterministically. + */ +export const dragGroupCardOntoCard = async (root: Page, sourceCardIndex: number, targetCardIndex: number) => { + const handle = getStepDragHandles(root).nth(sourceCardIndex); + const target = getRecipientStepCards(root).nth(targetCardIndex); + + await handle.scrollIntoViewIfNeeded(); + await handle.focus(); + + // Lift. + await root.keyboard.press('Space'); + await root.waitForTimeout(250); + + const direction = targetCardIndex < sourceCardIndex ? 'ArrowUp' : 'ArrowDown'; + + for (let press = 0; press < 4; press += 1) { + await root.keyboard.press(direction); + await root.waitForTimeout(250); + + const targetClassName = await target.getAttribute('class'); + + if (targetClassName?.includes('ring-primary')) { + // Drop while the target reports the combine state. + await root.keyboard.press('Space'); + await root.waitForTimeout(400); + + return; + } + } + + await root.keyboard.press('Escape'); + + throw new Error('Combine drag did not reach the target card'); +}; + +/** + * Moves a group card one position up via keyboard drag. With combining + * enabled, the first ArrowUp enters the combine state with the card above and + * the second moves above it. + */ +export const moveGroupCardUp = async (root: Page, cardIndex: number) => { + const handle = getStepDragHandles(root).nth(cardIndex); + + await handle.scrollIntoViewIfNeeded(); + await handle.focus(); + + await root.keyboard.press('Space'); + await root.waitForTimeout(250); + await root.keyboard.press('ArrowUp'); + await root.waitForTimeout(250); + await root.keyboard.press('ArrowUp'); + await root.waitForTimeout(250); + await root.keyboard.press('Space'); + await root.waitForTimeout(400); +}; + +/** + * Drags a recipient row into a gap between group cards, extracting it into + * its own standalone group at that position. + */ +export const dragRecipientRowToGap = async (root: Page, rowIndex: number, gapIndex: number) => { + await dragHandleToTarget( + root, + getRecipientRowDragHandles(root).nth(rowIndex), + getRecipientStepGaps(root).nth(gapIndex), + // The marker class applied to a gap drop-zone while dragged over. + { activeClass: 'gap-active' }, + ); +}; + +export type SweepRecipientRowOverCardResult = { + /** + * Whether any gap drop-zone activated during the sweep — proof the drag + * gesture itself was live, so "the card never activated" cannot be a + * false negative from a drag that silently failed to start. + */ + sawGapActive: boolean; + /** + * Whether the target card reported the row as a join target (`ring-primary`). + */ + sawCardActive: boolean; + /** + * Whether the row was dropped onto the card (only when it became active). + */ + dropped: boolean; +}; + +/** + * Drags a recipient row across a group card's body, dropping it to join the + * group as soon as the card activates. If the card never activates (e.g. the + * join drop-zone is disabled), the drag is cancelled with Escape so no + * accidental gap-drop mutates the order. + * + * Unlike `dragHandleToTarget`'s fixed-interval crawl, each sweep position + * polls for activation with a generous budget, which keeps the gesture + * reliable when rendering lags under parallel test load. + */ +export const sweepRecipientRowOverCard = async ( + root: Page, + rowIndex: number, + cardIndex: number, +): Promise => { + const handle = getRecipientRowDragHandles(root).nth(rowIndex); + const card = getRecipientStepCards(root).nth(cardIndex); + + const result: SweepRecipientRowOverCardResult = { + sawGapActive: false, + sawCardActive: false, + dropped: false, + }; + + await handle.scrollIntoViewIfNeeded(); + + const handleBox = await handle.boundingBox(); + + if (!handleBox) { + throw new Error('Unable to resolve drag handle position'); + } + + const startX = handleBox.x + handleBox.width / 2; + const startY = handleBox.y + handleBox.height / 2; + + await root.mouse.move(startX, startY); + await root.mouse.down(); + + // Exceed the drag activation threshold, then wait for drag-dependent + // layout (expanding drop-zones) to settle before resolving positions. + const cursorX = startX + 8; + const cursorY = startY; + + await root.mouse.move(cursorX, cursorY, { steps: 2 }); + await root.waitForTimeout(300); + + // Drop targeting uses the dragged element's CENTRE, not the cursor, so + // cursor destinations are compensated by the constant cursor-to-centre + // offset captured at lift time. + const draggedElement = handle.locator('xpath=ancestor-or-self::*[@data-rfd-draggable-id][1]'); + const draggedBox = await draggedElement.boundingBox(); + const cardBox = await card.boundingBox(); + + if (!draggedBox || !cardBox) { + await root.mouse.up(); + + throw new Error('Unable to resolve drag positions'); + } + + const itemOffsetX = draggedBox.x + draggedBox.width / 2 - cursorX; + const itemOffsetY = draggedBox.y + draggedBox.height / 2 - cursorY; + + const sweepX = cardBox.x + cardBox.width / 2 - itemOffsetX; + const sweepFromY = cardBox.y - itemOffsetY - 40; + const sweepToY = cardBox.y + cardBox.height - itemOffsetY + 80; + + await root.mouse.move(sweepX, sweepFromY, { steps: 15 }); + + for (let y = sweepFromY; y <= sweepToY && !result.dropped; y += 8) { + await root.mouse.move(sweepX, y, { steps: 2 }); + + // Poll for activation: drag state is rendered on animation frames, so + // under load the classes can trail the cursor by hundreds of ms. + for (let tick = 0; tick < 6; tick += 1) { + const cardClassName = (await card.getAttribute('class')) ?? ''; + + if (cardClassName.includes('ring-primary')) { + result.sawCardActive = true; + + await root.mouse.up(); + + result.dropped = true; + + break; + } + + if (!result.sawGapActive) { + const activeGapCount = await root.locator('[data-testid="recipient-step-gap"].gap-active').count(); + + result.sawGapActive = activeGapCount > 0; + } + + await root.waitForTimeout(50); + } + } + + if (!result.dropped) { + // Cancel rather than release: releasing over an active gap would extract + // the row into a new step, silently mutating the signing order. + await root.keyboard.press('Escape'); + await root.waitForTimeout(100); + await root.mouse.up(); + } + + await root.waitForTimeout(400); + + return result; }; export const persistEmbeddedEnvelope = async (surface: TEnvelopeEditorSurface) => { diff --git a/packages/lib/server-only/envelope/create-envelope.ts b/packages/lib/server-only/envelope/create-envelope.ts index 2bbb079fd..98e90efab 100644 --- a/packages/lib/server-only/envelope/create-envelope.ts +++ b/packages/lib/server-only/envelope/create-envelope.ts @@ -38,7 +38,9 @@ import { createDocumentAuthOptions, createRecipientAuthOptions } from '../../uti import { buildTeamWhereQuery } from '../../utils/teams'; import { incrementDocumentId, incrementTemplateId } from '../envelope/increment-id'; import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits'; +import { assertCompatibleRecipientGrouping } from '../signature-level/assert-compatible-recipient-grouping'; import { assertCompatibleRecipientRole } from '../signature-level/assert-compatible-recipient-role'; +import { assignDefaultRecipientSigningOrders } from '../signature-level/assign-default-recipient-signing-orders'; import { resolveSignatureLevel } from '../signature-level/resolve-signature-level'; import { getTeamSettings } from '../team/get-team-settings'; import { assertUserNotDisabledById } from '../user/assert-user-not-disabled'; @@ -280,6 +282,31 @@ export const createEnvelope = async ({ assertCompatibleRecipientRole({ signatureLevel, role: recipient.role }); } + const parsedDefaultRecipients = + settings.defaultRecipients && !bypassDefaultRecipients + ? ZDefaultRecipientsSchema.parse(settings.defaultRecipients) + : []; + + const defaultRecipients: CreateEnvelopeRecipientOptions[] = parsedDefaultRecipients.map((recipient) => ({ + email: recipient.email, + name: recipient.name, + role: recipient.role, + })); + + // Assign default recipients signing orders if TSP is enabled since + // TSP envelopes require sequential signing. + const orderedDefaultRecipients = assignDefaultRecipientSigningOrders({ + signatureLevel, + payloadRecipients: data.recipients ?? [], + defaultRecipients, + }); + + const recipientsToCreate = [...(data.recipients || []), ...orderedDefaultRecipients]; + + // The grouping assertion runs against the COMBINED set the envelope will + // actually hold, not just the payload. + assertCompatibleRecipientGrouping({ signatureLevel, recipients: recipientsToCreate }); + const visibility = visibilityOverride || settings.documentVisibility; const emailId = meta?.emailId; @@ -403,21 +430,8 @@ export const createEnvelope = async ({ const firstEnvelopeItem = envelope.envelopeItems[0]; - const defaultRecipients = - settings.defaultRecipients && !bypassDefaultRecipients - ? ZDefaultRecipientsSchema.parse(settings.defaultRecipients) - : []; - - const mappedDefaultRecipients: CreateEnvelopeRecipientOptions[] = defaultRecipients.map((recipient) => ({ - email: recipient.email, - name: recipient.name, - role: recipient.role, - })); - - const allRecipients = [...(data.recipients || []), ...mappedDefaultRecipients]; - await Promise.all( - allRecipients.map(async (recipient) => { + recipientsToCreate.map(async (recipient) => { const recipientAuthOptions = createRecipientAuthOptions({ accessAuth: recipient.accessAuth ?? [], actionAuth: recipient.actionAuth ?? [], diff --git a/packages/lib/server-only/recipient/create-envelope-recipients.ts b/packages/lib/server-only/recipient/create-envelope-recipients.ts index 58d5e68bd..01a2a6767 100644 --- a/packages/lib/server-only/recipient/create-envelope-recipients.ts +++ b/packages/lib/server-only/recipient/create-envelope-recipients.ts @@ -12,6 +12,7 @@ import type { EnvelopeIdOptions } from '../../utils/envelope'; import { mapRecipientToLegacyRecipient } from '../../utils/recipients'; import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable'; import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; +import { assertCompatibleRecipientGrouping } from '../signature-level/assert-compatible-recipient-grouping'; import { assertCompatibleRecipientRole } from '../signature-level/assert-compatible-recipient-role'; export interface CreateEnvelopeRecipientsOptions { @@ -91,6 +92,13 @@ export const createEnvelopeRecipients = async ({ }); } + // Grouping is a property of the whole recipient set, so check the state the + // envelope will be left in rather than the incoming batch alone. + assertCompatibleRecipientGrouping({ + signatureLevel: envelope.signatureLevel, + recipients: [...envelope.recipients, ...recipientsToCreate], + }); + const normalizedRecipients = recipientsToCreate.map((recipient) => ({ ...recipient, email: recipient.email.toLowerCase(), diff --git a/packages/lib/server-only/recipient/set-document-recipients.ts b/packages/lib/server-only/recipient/set-document-recipients.ts index ca2ca666d..3227e3296 100644 --- a/packages/lib/server-only/recipient/set-document-recipients.ts +++ b/packages/lib/server-only/recipient/set-document-recipients.ts @@ -17,6 +17,7 @@ import { type EnvelopeIdOptions, mapSecondaryIdToDocumentId } from '../../utils/ import { canRecipientBeModified, isRecipientEmailValidForSending } from '../../utils/recipients'; import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable'; import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; +import { assertCompatibleRecipientGrouping } from '../signature-level/assert-compatible-recipient-grouping'; import { assertCompatibleRecipientRole } from '../signature-level/assert-compatible-recipient-role'; export interface SetDocumentRecipientsOptions { @@ -98,6 +99,11 @@ export const setDocumentRecipients = async ({ }); } + assertCompatibleRecipientGrouping({ + signatureLevel: envelope.signatureLevel, + recipients, + }); + const normalizedRecipients = recipients.map((recipient) => ({ ...recipient, email: recipient.email.toLowerCase(), diff --git a/packages/lib/server-only/recipient/set-template-recipients.ts b/packages/lib/server-only/recipient/set-template-recipients.ts index b177f3315..2ea330ec4 100644 --- a/packages/lib/server-only/recipient/set-template-recipients.ts +++ b/packages/lib/server-only/recipient/set-template-recipients.ts @@ -12,6 +12,7 @@ import { nanoid } from '../../universal/id'; import { createRecipientAuthOptions } from '../../utils/document-auth'; import { type EnvelopeIdOptions, mapSecondaryIdToTemplateId } from '../../utils/envelope'; import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; +import { assertCompatibleRecipientGrouping } from '../signature-level/assert-compatible-recipient-grouping'; import { assertCompatibleRecipientRole } from '../signature-level/assert-compatible-recipient-role'; export type SetTemplateRecipientsOptions = { @@ -68,6 +69,11 @@ export const setTemplateRecipients = async ({ userId, teamId, id, recipients }: }); } + assertCompatibleRecipientGrouping({ + signatureLevel: envelope.signatureLevel, + recipients, + }); + const normalizedRecipients = recipients.map((recipient) => { // Force replace any changes to the name or email of the direct recipient. if (envelope.directLink && recipient.id === envelope.directLink.directTemplateRecipientId) { diff --git a/packages/lib/server-only/recipient/update-envelope-recipients.ts b/packages/lib/server-only/recipient/update-envelope-recipients.ts index 553b30543..4ffb372e2 100644 --- a/packages/lib/server-only/recipient/update-envelope-recipients.ts +++ b/packages/lib/server-only/recipient/update-envelope-recipients.ts @@ -14,6 +14,7 @@ import { mapFieldToLegacyField } from '../../utils/fields'; import { canRecipientBeModified } from '../../utils/recipients'; import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable'; import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; +import { assertCompatibleRecipientGrouping } from '../signature-level/assert-compatible-recipient-grouping'; import { assertCompatibleRecipientRole } from '../signature-level/assert-compatible-recipient-role'; export interface UpdateEnvelopeRecipientsOptions { @@ -99,6 +100,16 @@ export const updateEnvelopeRecipients = async ({ }); } + assertCompatibleRecipientGrouping({ + signatureLevel: envelope.signatureLevel, + // Combine the existing recipients with the new ones to see if the grouping is compatible. + recipients: envelope.recipients.map((existingRecipient) => { + const update = recipients.find((recipient) => recipient.id === existingRecipient.id); + + return update ? { ...existingRecipient, ...update } : existingRecipient; + }), + }); + const recipientsToUpdate = recipients.map((recipient) => { const originalRecipient = envelope.recipients.find((existingRecipient) => existingRecipient.id === recipient.id); diff --git a/packages/lib/server-only/signature-level/assign-default-recipient-signing-orders.ts b/packages/lib/server-only/signature-level/assign-default-recipient-signing-orders.ts new file mode 100644 index 000000000..ea11739a2 --- /dev/null +++ b/packages/lib/server-only/signature-level/assign-default-recipient-signing-orders.ts @@ -0,0 +1,57 @@ +import type { Recipient } from '@prisma/client'; + +import { isTspEnvelope } from '../../types/signature-level'; +import { isCcRecipient } from '../../utils/recipients'; + +type AssignDefaultRecipientSigningOrdersOptions = { + signatureLevel: string; + + /** + * The recipients supplied by the caller, which should already be validated by + * {@link assertCompatibleRecipientGrouping}. + */ + payloadRecipients: Array & { signingOrder?: number | null }>; + + /** + * The team default recipients to append. + */ + defaultRecipients: T[]; +}; + +/** + * Assigns distinct signing orders to default recipients appended to a + * TSP (AES/QES) envelope. + * + * Default recipients carry no signing order, so on a TSP envelope two or more of + * them would share the unordered tail step — a signing group, which TSP + * signatures cannot hold + * + * CC defaults are left unordered: they never sign and are ignored by the + * grouping assertion. + * + * SES envelopes pass through unchanged — shared steps are an SES feature. + */ +export const assignDefaultRecipientSigningOrders = >({ + signatureLevel, + payloadRecipients, + defaultRecipients, +}: AssignDefaultRecipientSigningOrdersOptions): Array => { + if (!isTspEnvelope({ signatureLevel })) { + return defaultRecipients; + } + + let nextOrder = + payloadRecipients.reduce((highest, recipient) => Math.max(highest, recipient.signingOrder ?? 0), 0) + 1; + + return defaultRecipients.map((recipient) => { + if (isCcRecipient(recipient)) { + return recipient; + } + + const signingOrder = nextOrder; + + nextOrder += 1; + + return { ...recipient, signingOrder }; + }); +}; diff --git a/packages/lib/types/recipient.ts b/packages/lib/types/recipient.ts index 25282846a..45b9ac89e 100644 --- a/packages/lib/types/recipient.ts +++ b/packages/lib/types/recipient.ts @@ -125,3 +125,14 @@ export type TEnvelopeRecipientLite = z.infer; export const ZRecipientEmailSchema = z.union([z.literal(''), zEmail('Invalid email').trim().toLowerCase().max(254)]); + +/** + * Signing order for a recipient, for use in request schemas. + * + * Response schemas intentionally do not use this: existing rows may hold values + * that predate the constraint, and reads must not fail because of it. + */ +export const ZRecipientSigningOrderSchema = z + .number() + .int('Signing order must be an integer') + .min(1, 'Signing order must be greater than 0'); diff --git a/packages/lib/utils/recipient-groups.test.ts b/packages/lib/utils/recipient-groups.test.ts index 25f629472..e0cbce8d3 100644 --- a/packages/lib/utils/recipient-groups.test.ts +++ b/packages/lib/utils/recipient-groups.test.ts @@ -3,9 +3,9 @@ import { describe, expect, it } from 'vitest'; import { extractRecipientToNewStep, - filterRecipientsInFirstSigningGroup, flattenRecipientGroups, getNextDictatableRecipient, + getRecipientsInActiveSigningStep, groupRecipientsBySigningOrder, isRecipientTurnBySigningOrder, mergeSteps, @@ -554,7 +554,7 @@ describe('isRecipientTurnBySigningOrder', () => { }); }); -describe('filterRecipientsInFirstSigningGroup', () => { +describe('getRecipientsInActiveSigningStep', () => { const candidate = ( id: number, signingOrder: number | null, @@ -565,17 +565,17 @@ describe('filterRecipientsInFirstSigningGroup', () => { it('returns every pending recipient sharing the lowest order', () => { const recipients = [candidate(3, 2), candidate(4, 2), candidate(5, 3)]; - expect(filterRecipientsInFirstSigningGroup(recipients).map((r) => r.id)).toEqual([3, 4]); + expect(getRecipientsInActiveSigningStep(recipients).map((r) => r.id)).toEqual([3, 4]); }); it('returns an empty array for no pending recipients', () => { - expect(filterRecipientsInFirstSigningGroup([])).toEqual([]); + expect(getRecipientsInActiveSigningStep([])).toEqual([]); }); it('excludes recipients that have already signed', () => { const recipients = [candidate(1, 1, SigningStatus.SIGNED), candidate(2, 2)]; - expect(filterRecipientsInFirstSigningGroup(recipients).map((r) => r.id)).toEqual([2]); + expect(getRecipientsInActiveSigningStep(recipients).map((r) => r.id)).toEqual([2]); }); // A rejected recipient is not pending: advancing to them would re-activate @@ -583,7 +583,7 @@ describe('filterRecipientsInFirstSigningGroup', () => { it('excludes rejected recipients', () => { const recipients = [candidate(1, 1, SigningStatus.REJECTED), candidate(2, 2)]; - expect(filterRecipientsInFirstSigningGroup(recipients).map((r) => r.id)).toEqual([2]); + expect(getRecipientsInActiveSigningStep(recipients).map((r) => r.id)).toEqual([2]); }); it('excludes CC recipients', () => { @@ -592,13 +592,13 @@ describe('filterRecipientsInFirstSigningGroup', () => { candidate(2, 2, SigningStatus.NOT_SIGNED), ]; - expect(filterRecipientsInFirstSigningGroup(recipients).map((r) => r.id)).toEqual([2]); + 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(filterRecipientsInFirstSigningGroup(recipients)).toEqual([]); + expect(getRecipientsInActiveSigningStep(recipients)).toEqual([]); }); }); diff --git a/packages/trpc/server/embedding-router/create-embedding-document.types.ts b/packages/trpc/server/embedding-router/create-embedding-document.types.ts index bdbdd1436..0c15f08b3 100644 --- a/packages/trpc/server/embedding-router/create-embedding-document.types.ts +++ b/packages/trpc/server/embedding-router/create-embedding-document.types.ts @@ -19,6 +19,7 @@ import { ZFieldWidthSchema, } from '@documenso/lib/types/field'; import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta'; +import { ZRecipientSigningOrderSchema } from '@documenso/lib/types/recipient'; import { zEmail } from '@documenso/lib/utils/zod'; import { RecipientRole } from '@documenso/prisma/client'; import { DocumentSigningOrder } from '@documenso/prisma/generated/types'; @@ -36,7 +37,7 @@ export const ZCreateEmbeddingDocumentRequestSchema = z.object({ email: zEmail(), name: z.string(), role: z.nativeEnum(RecipientRole), - signingOrder: z.number().optional(), + signingOrder: ZRecipientSigningOrderSchema.optional(), // We have an any cast so any changes here you need to update it in the embeding document edit page // Search: "map" to find it fields: ZFieldAndMetaSchema.and( diff --git a/packages/trpc/server/embedding-router/create-embedding-template.types.ts b/packages/trpc/server/embedding-router/create-embedding-template.types.ts index 8bacaacf0..05d50b7f3 100644 --- a/packages/trpc/server/embedding-router/create-embedding-template.types.ts +++ b/packages/trpc/server/embedding-router/create-embedding-template.types.ts @@ -19,7 +19,7 @@ import { ZFieldWidthSchema, } from '@documenso/lib/types/field'; import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta'; -import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient'; +import { ZRecipientEmailSchema, ZRecipientSigningOrderSchema } from '@documenso/lib/types/recipient'; import { DocumentSigningOrder, RecipientRole } from '@prisma/client'; import { z } from 'zod'; @@ -33,7 +33,7 @@ export const ZCreateEmbeddingTemplateRequestSchema = z.object({ email: ZRecipientEmailSchema, name: z.string(), role: z.nativeEnum(RecipientRole), - signingOrder: z.number().optional(), + signingOrder: ZRecipientSigningOrderSchema.optional(), // We have an any cast so any changes here you need to update it in the embeding document edit page // Search: "map" to find it fields: ZFieldAndMetaSchema.and( diff --git a/packages/trpc/server/embedding-router/update-embedding-document.types.ts b/packages/trpc/server/embedding-router/update-embedding-document.types.ts index 753ab7224..238499c1c 100644 --- a/packages/trpc/server/embedding-router/update-embedding-document.types.ts +++ b/packages/trpc/server/embedding-router/update-embedding-document.types.ts @@ -19,6 +19,7 @@ import { ZFieldWidthSchema, } from '@documenso/lib/types/field'; import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta'; +import { ZRecipientSigningOrderSchema } from '@documenso/lib/types/recipient'; import { zEmail } from '@documenso/lib/utils/zod'; import { DocumentSigningOrder, RecipientRole } from '@documenso/prisma/generated/types'; import { z } from 'zod'; @@ -35,7 +36,7 @@ export const ZUpdateEmbeddingDocumentRequestSchema = z.object({ email: zEmail(), name: z.string(), role: z.nativeEnum(RecipientRole), - signingOrder: z.number().optional(), + signingOrder: ZRecipientSigningOrderSchema.optional(), // We have an any cast so any changes here you need to update it in the embeding document edit page // Search: "map" to find it fields: ZFieldAndMetaSchema.and( diff --git a/packages/trpc/server/embedding-router/update-embedding-template.types.ts b/packages/trpc/server/embedding-router/update-embedding-template.types.ts index 6fa3571f7..3f7103057 100644 --- a/packages/trpc/server/embedding-router/update-embedding-template.types.ts +++ b/packages/trpc/server/embedding-router/update-embedding-template.types.ts @@ -19,7 +19,7 @@ import { ZFieldWidthSchema, } from '@documenso/lib/types/field'; import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta'; -import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient'; +import { ZRecipientEmailSchema, ZRecipientSigningOrderSchema } from '@documenso/lib/types/recipient'; import { DocumentSigningOrder, RecipientRole } from '@prisma/client'; import { z } from 'zod'; @@ -35,7 +35,7 @@ export const ZUpdateEmbeddingTemplateRequestSchema = z.object({ email: ZRecipientEmailSchema, name: z.string(), role: z.nativeEnum(RecipientRole), - signingOrder: z.number().optional(), + signingOrder: ZRecipientSigningOrderSchema.optional(), // We have an any cast so any changes here you need to update it in the embeding document edit page // Search: "map" to find it fields: ZFieldAndMetaSchema.and( diff --git a/packages/trpc/server/envelope-router/envelope-recipients/create-envelope-recipients.types.ts b/packages/trpc/server/envelope-router/envelope-recipients/create-envelope-recipients.types.ts index e5d20a9cb..816278646 100644 --- a/packages/trpc/server/envelope-router/envelope-recipients/create-envelope-recipients.types.ts +++ b/packages/trpc/server/envelope-router/envelope-recipients/create-envelope-recipients.types.ts @@ -1,5 +1,9 @@ import { ZRecipientAccessAuthTypesSchema, ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-auth'; -import { ZEnvelopeRecipientLiteSchema, ZRecipientEmailSchema } from '@documenso/lib/types/recipient'; +import { + ZEnvelopeRecipientLiteSchema, + ZRecipientEmailSchema, + ZRecipientSigningOrderSchema, +} from '@documenso/lib/types/recipient'; import { RecipientRole } from '@prisma/client'; import { z } from 'zod'; @@ -19,7 +23,7 @@ export const ZCreateEnvelopeRecipientSchema = z.object({ email: ZRecipientEmailSchema, name: z.string().max(255), role: z.nativeEnum(RecipientRole), - signingOrder: z.number().optional(), + signingOrder: ZRecipientSigningOrderSchema.optional(), accessAuth: z.array(ZRecipientAccessAuthTypesSchema).default([]).optional(), actionAuth: z.array(ZRecipientActionAuthTypesSchema).default([]).optional(), }); diff --git a/packages/trpc/server/envelope-router/set-envelope-recipients.types.ts b/packages/trpc/server/envelope-router/set-envelope-recipients.types.ts index a1cadc346..70c09fdc9 100644 --- a/packages/trpc/server/envelope-router/set-envelope-recipients.types.ts +++ b/packages/trpc/server/envelope-router/set-envelope-recipients.types.ts @@ -1,14 +1,22 @@ import { ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-auth'; -import { ZRecipientEmailSchema, ZRecipientLiteSchema } from '@documenso/lib/types/recipient'; +import { + ZRecipientEmailSchema, + ZRecipientLiteSchema, + ZRecipientSigningOrderSchema, +} from '@documenso/lib/types/recipient'; import { EnvelopeType, RecipientRole } from '@prisma/client'; import { z } from 'zod'; export const ZSetEnvelopeRecipientSchema = z.object({ id: z.number().optional(), + clientId: z + .string() + .optional() + .describe('A temporary ID echoed back on the response so newly created recipients can be reconciled'), email: ZRecipientEmailSchema, name: z.string().max(255), role: z.nativeEnum(RecipientRole), - signingOrder: z.number().optional(), + signingOrder: ZRecipientSigningOrderSchema.optional(), actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]), }); @@ -22,7 +30,11 @@ export const ZSetEnvelopeRecipientsResponseSchema = z.object({ data: ZRecipientLiteSchema.omit({ documentId: true, templateId: true, - }).array(), + }) + .extend({ + clientId: z.string().nullish(), + }) + .array(), }); export type TSetEnvelopeRecipientsRequest = z.infer; diff --git a/packages/trpc/server/recipient-router/schema.ts b/packages/trpc/server/recipient-router/schema.ts index 582688f05..6bb2cb6dc 100644 --- a/packages/trpc/server/recipient-router/schema.ts +++ b/packages/trpc/server/recipient-router/schema.ts @@ -5,7 +5,7 @@ import { ZRecipientActionAuthSchema, ZRecipientActionAuthTypesSchema, } from '@documenso/lib/types/document-auth'; -import { ZRecipientLiteSchema, ZRecipientSchema } from '@documenso/lib/types/recipient'; +import { ZRecipientLiteSchema, ZRecipientSchema, ZRecipientSigningOrderSchema } from '@documenso/lib/types/recipient'; import { zEmail } from '@documenso/lib/utils/zod'; import { RecipientRole } from '@prisma/client'; import { z } from 'zod'; @@ -27,7 +27,7 @@ export const ZCreateRecipientSchema = z.object({ email: zEmail().toLowerCase().min(1).max(254), name: z.string().max(255), role: z.nativeEnum(RecipientRole), - signingOrder: z.number().optional(), + signingOrder: ZRecipientSigningOrderSchema.optional(), accessAuth: z.array(ZRecipientAccessAuthTypesSchema).default([]).optional(), actionAuth: z.array(ZRecipientActionAuthTypesSchema).default([]).optional(), }); @@ -37,7 +37,7 @@ export const ZUpdateRecipientSchema = z.object({ email: zEmail().toLowerCase().min(1).max(254).optional(), name: z.string().max(255).optional(), role: z.nativeEnum(RecipientRole).optional(), - signingOrder: z.number().optional(), + signingOrder: ZRecipientSigningOrderSchema.optional(), accessAuth: z.array(ZRecipientAccessAuthTypesSchema).default([]).optional(), actionAuth: z.array(ZRecipientActionAuthTypesSchema).default([]).optional(), }); @@ -86,7 +86,7 @@ export const ZSetDocumentRecipientsRequestSchema = z.object({ email: zEmail().toLowerCase().min(1).max(254), name: z.string().max(255), role: z.nativeEnum(RecipientRole), - signingOrder: z.number().optional(), + signingOrder: ZRecipientSigningOrderSchema.optional(), actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]), }), ), @@ -148,7 +148,7 @@ export const ZSetTemplateRecipientsRequestSchema = z.object({ ), name: z.string(), role: z.nativeEnum(RecipientRole), - signingOrder: z.number().optional(), + signingOrder: ZRecipientSigningOrderSchema.optional(), actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]), }), ),