mirror of
https://github.com/documenso/documenso.git
synced 2026-10-03 18:23:54 +10:00
fix: reviewed
This commit is contained in:
@@ -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<SweepRecipientRowOverCardResult> => {
|
||||
const handle = getRecipientRowDragHandles(root).nth(rowIndex);
|
||||
const card = getRecipientStepCards(root).nth(cardIndex);
|
||||
|
||||
const result: SweepRecipientRowOverCardResult = {
|
||||
sawGapActive: false,
|
||||
sawCardActive: false,
|
||||
dropped: false,
|
||||
};
|
||||
|
||||
await handle.scrollIntoViewIfNeeded();
|
||||
|
||||
const handleBox = await handle.boundingBox();
|
||||
|
||||
if (!handleBox) {
|
||||
throw new Error('Unable to resolve drag handle position');
|
||||
}
|
||||
|
||||
const startX = handleBox.x + handleBox.width / 2;
|
||||
const startY = handleBox.y + handleBox.height / 2;
|
||||
|
||||
await root.mouse.move(startX, startY);
|
||||
await root.mouse.down();
|
||||
|
||||
// Exceed the drag activation threshold, then wait for drag-dependent
|
||||
// layout (expanding drop-zones) to settle before resolving positions.
|
||||
const cursorX = startX + 8;
|
||||
const cursorY = startY;
|
||||
|
||||
await root.mouse.move(cursorX, cursorY, { steps: 2 });
|
||||
await root.waitForTimeout(300);
|
||||
|
||||
// Drop targeting uses the dragged element's CENTRE, not the cursor, so
|
||||
// cursor destinations are compensated by the constant cursor-to-centre
|
||||
// offset captured at lift time.
|
||||
const draggedElement = handle.locator('xpath=ancestor-or-self::*[@data-rfd-draggable-id][1]');
|
||||
const draggedBox = await draggedElement.boundingBox();
|
||||
const cardBox = await card.boundingBox();
|
||||
|
||||
if (!draggedBox || !cardBox) {
|
||||
await root.mouse.up();
|
||||
|
||||
throw new Error('Unable to resolve drag positions');
|
||||
}
|
||||
|
||||
const itemOffsetX = draggedBox.x + draggedBox.width / 2 - cursorX;
|
||||
const itemOffsetY = draggedBox.y + draggedBox.height / 2 - cursorY;
|
||||
|
||||
const sweepX = cardBox.x + cardBox.width / 2 - itemOffsetX;
|
||||
const sweepFromY = cardBox.y - itemOffsetY - 40;
|
||||
const sweepToY = cardBox.y + cardBox.height - itemOffsetY + 80;
|
||||
|
||||
await root.mouse.move(sweepX, sweepFromY, { steps: 15 });
|
||||
|
||||
for (let y = sweepFromY; y <= sweepToY && !result.dropped; y += 8) {
|
||||
await root.mouse.move(sweepX, y, { steps: 2 });
|
||||
|
||||
// Poll for activation: drag state is rendered on animation frames, so
|
||||
// under load the classes can trail the cursor by hundreds of ms.
|
||||
for (let tick = 0; tick < 6; tick += 1) {
|
||||
const cardClassName = (await card.getAttribute('class')) ?? '';
|
||||
|
||||
if (cardClassName.includes('ring-primary')) {
|
||||
result.sawCardActive = true;
|
||||
|
||||
await root.mouse.up();
|
||||
|
||||
result.dropped = true;
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
if (!result.sawGapActive) {
|
||||
const activeGapCount = await root.locator('[data-testid="recipient-step-gap"].gap-active').count();
|
||||
|
||||
result.sawGapActive = activeGapCount > 0;
|
||||
}
|
||||
|
||||
await root.waitForTimeout(50);
|
||||
}
|
||||
}
|
||||
|
||||
if (!result.dropped) {
|
||||
// Cancel rather than release: releasing over an active gap would extract
|
||||
// the row into a new step, silently mutating the signing order.
|
||||
await root.keyboard.press('Escape');
|
||||
await root.waitForTimeout(100);
|
||||
await root.mouse.up();
|
||||
}
|
||||
|
||||
await root.waitForTimeout(400);
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const persistEmbeddedEnvelope = async (surface: TEnvelopeEditorSurface) => {
|
||||
|
||||
@@ -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 ?? [],
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { Recipient } from '@prisma/client';
|
||||
|
||||
import { isTspEnvelope } from '../../types/signature-level';
|
||||
import { isCcRecipient } from '../../utils/recipients';
|
||||
|
||||
type AssignDefaultRecipientSigningOrdersOptions<T> = {
|
||||
signatureLevel: string;
|
||||
|
||||
/**
|
||||
* The recipients supplied by the caller, which should already be validated by
|
||||
* {@link assertCompatibleRecipientGrouping}.
|
||||
*/
|
||||
payloadRecipients: Array<Pick<Recipient, 'role'> & { signingOrder?: number | null }>;
|
||||
|
||||
/**
|
||||
* The team default recipients to append.
|
||||
*/
|
||||
defaultRecipients: T[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Assigns distinct signing orders to 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 = <T extends Pick<Recipient, 'role'>>({
|
||||
signatureLevel,
|
||||
payloadRecipients,
|
||||
defaultRecipients,
|
||||
}: AssignDefaultRecipientSigningOrdersOptions<T>): Array<T & { signingOrder?: number }> => {
|
||||
if (!isTspEnvelope({ signatureLevel })) {
|
||||
return defaultRecipients;
|
||||
}
|
||||
|
||||
let nextOrder =
|
||||
payloadRecipients.reduce((highest, recipient) => Math.max(highest, recipient.signingOrder ?? 0), 0) + 1;
|
||||
|
||||
return defaultRecipients.map((recipient) => {
|
||||
if (isCcRecipient(recipient)) {
|
||||
return recipient;
|
||||
}
|
||||
|
||||
const signingOrder = nextOrder;
|
||||
|
||||
nextOrder += 1;
|
||||
|
||||
return { ...recipient, signingOrder };
|
||||
});
|
||||
};
|
||||
@@ -125,3 +125,14 @@ export type TEnvelopeRecipientLite = z.infer<typeof ZEnvelopeRecipientLiteSchema
|
||||
export type TEnvelopeRecipientMany = z.infer<typeof ZEnvelopeRecipientManySchema>;
|
||||
|
||||
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');
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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<any>" to find it
|
||||
fields: ZFieldAndMetaSchema.and(
|
||||
|
||||
@@ -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<any>" to find it
|
||||
fields: ZFieldAndMetaSchema.and(
|
||||
|
||||
@@ -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<any>" to find it
|
||||
fields: ZFieldAndMetaSchema.and(
|
||||
|
||||
@@ -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<any>" to find it
|
||||
fields: ZFieldAndMetaSchema.and(
|
||||
|
||||
+6
-2
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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<typeof ZSetEnvelopeRecipientsRequestSchema>;
|
||||
|
||||
@@ -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([]),
|
||||
}),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user