102 KiB
date, title
| date | title |
|---|---|
| 2026-08-04 | Recipient Signing Groups Implementation |
Recipient Signing Groups Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Allow recipients to share a signingOrder value ("signing groups") in the V2 envelope editor so grouped recipients sign in any order among themselves, gating the next step until all members complete.
Architecture: Groups are derived from duplicate signingOrder values — no schema change (Recipient.signingOrder is already a nullable, non-unique Int). Pure group utilities live in packages/lib/utils/recipient-groups.ts and are shared by the editor UI and the server signing flow. The editor renders steps as cards with nested Kanban drag-and-drop (@hello-pangea/dnd: outer STEP droppable with combine, inner RECIPIENT droppables, gap drop-zones). Server "single next recipient" decision points switch to group semantics (value-based turn check, min-order group activation).
Tech Stack: TypeScript, React (Remix), react-hook-form, Zod, @hello-pangea/dnd, Prisma, tRPC, vitest, Playwright.
Spec: .agents/plans/quiet-jade-river-recipient-signing-groups.md — read it first.
Repo rules that apply to every task: no classes, type over interface, named exports, no 1-line if statements, <Trans>/t` `/plural macros for all user-facing strings, const Component = () => {} for components.
File Map
| File | Action | Responsibility |
|---|---|---|
packages/lib/utils/recipient-groups.ts |
Create | Pure group derivation, normalization, editor operations, turn predicate, dictation helper |
packages/lib/utils/recipient-groups.test.ts |
Create | Unit tests for all of the above |
packages/lib/utils/recipients.ts |
Modify | Group-aware isAssistantLastSigner, new canEditorRecipientBeModified |
packages/lib/utils/recipients.test.ts |
Modify | Tests for group-aware assistant check |
packages/lib/server-only/recipient/get-is-recipient-turn.ts |
Modify | Value-based turn check |
packages/lib/server-only/envelope/get-envelope-for-recipient-signing.ts |
Modify | Delete inline turn loop, use shared predicate |
packages/lib/server-only/document/send-document.ts |
Modify | Notify whole first pending group |
packages/lib/server-only/document/complete-document-with-token.ts |
Modify | Group advance + dictation gating |
packages/lib/server-only/template/create-document-from-direct-template.ts |
Modify | Dictation only for single-member next step |
packages/lib/server-only/recipient/get-next-pending-recipient.ts |
Modify | Use getDictatableNextRecipient |
packages/lib/server-only/recipient/get-recipients-for-assistant.ts |
Modify | Strictly-greater assistant scope (self preserved) |
packages/trpc/server/envelope-router/sign-envelope-field.ts |
Modify | Strictly-greater assistant scope (self preserved) |
apps/remix/app/components/general/document-signing/envelope-signing-provider.tsx |
Modify | Use getDictatableNextRecipient |
apps/remix/app/components/general/document-signing/document-signing-page-view-v1.tsx |
Modify | Use getDictatableNextRecipient |
apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx |
Modify | Use getDictatableNextRecipient |
packages/lib/client-only/hooks/use-editor-recipients.ts |
Modify | Grouped normalize on load, CSC no-duplicates validation |
apps/remix/app/components/general/envelope-editor/recipient-row.tsx |
Create | Single recipient row (moved from form) |
apps/remix/app/components/general/envelope-editor/recipient-step-card.tsx |
Create | Step card chrome, inner droppable, group header |
apps/remix/app/components/general/envelope-editor/recipient-step-list.tsx |
Create | DnD orchestration, gap zones, all mutation handlers |
apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx |
Modify | Slim down to header/checkboxes/sync + <RecipientStepList /> |
packages/app-tests/e2e/envelope-editor-v2/envelope-recipient-groups.spec.ts |
Create | Editor E2E (type-to-join, ungroup, persistence) |
packages/app-tests/e2e/recipient/signing-groups.spec.ts |
Create | Signing-flow E2E (any-order group, gating) |
Execution note: create an isolated worktree first (superpowers:using-git-worktrees). Tasks 1–4 are pure lib (TDD with vitest). Tasks 5–11 are backend/lib wiring (verified by tsc + existing tests + E2E later). Tasks 12–15 are UI. Tasks 16–17 are E2E. Task 18 is final verification.
Task 1: Group derivation + grouped normalization
Files:
-
Create:
packages/lib/utils/recipient-groups.ts -
Create:
packages/lib/utils/recipient-groups.test.ts -
Step 1: Write the failing tests
Create packages/lib/utils/recipient-groups.test.ts:
import { RecipientRole } from '@prisma/client';
import { describe, expect, it } from 'vitest';
import { groupRecipientsBySigningOrder, normalizeGroupedSigningOrders } from './recipient-groups';
describe('groupRecipientsBySigningOrder', () => {
it('groups non-CC recipients sharing a signing order into steps', () => {
const recipients = [
{ formId: 'a', role: RecipientRole.SIGNER, signingOrder: 1 },
{ formId: 'b', role: RecipientRole.SIGNER, signingOrder: 2 },
{ formId: 'c', role: RecipientRole.APPROVER, signingOrder: 2 },
{ formId: 'd', role: RecipientRole.SIGNER, signingOrder: 3 },
];
const { steps, ccRecipients } = groupRecipientsBySigningOrder(recipients);
expect(ccRecipients).toEqual([]);
expect(steps.map((step) => step.order)).toEqual([1, 2, 3]);
expect(steps.map((step) => step.members.map((m) => m.formId))).toEqual([['a'], ['b', 'c'], ['d']]);
});
it('excludes CC recipients from steps', () => {
const recipients = [
{ formId: 'a', role: RecipientRole.SIGNER, signingOrder: 1 },
{ formId: 'b', role: RecipientRole.CC, signingOrder: undefined },
];
const { steps, ccRecipients } = groupRecipientsBySigningOrder(recipients);
expect(steps).toHaveLength(1);
expect(ccRecipients.map((r) => r.formId)).toEqual(['b']);
});
it('sorts steps by order regardless of input order and keeps member input order', () => {
const recipients = [
{ formId: 'c', role: RecipientRole.SIGNER, signingOrder: 2 },
{ formId: 'a', role: RecipientRole.SIGNER, signingOrder: 1 },
{ formId: 'b', role: RecipientRole.SIGNER, signingOrder: 2 },
];
const { steps } = groupRecipientsBySigningOrder(recipients);
expect(steps.map((step) => step.members.map((m) => m.formId))).toEqual([['a'], ['c', 'b']]);
});
it('collects recipients without a signing order into a single tail step', () => {
const recipients = [
{ formId: 'a', role: RecipientRole.SIGNER, signingOrder: 1 },
{ formId: 'b', role: RecipientRole.SIGNER, signingOrder: null },
{ formId: 'c', role: RecipientRole.SIGNER, signingOrder: undefined },
];
const { steps } = groupRecipientsBySigningOrder(recipients);
expect(steps).toHaveLength(2);
expect(steps[1].members.map((m) => m.formId)).toEqual(['b', 'c']);
});
});
describe('normalizeGroupedSigningOrders', () => {
it('preserves groups while compacting gaps to dense step numbers', () => {
const recipients = [
{ formId: 'a', role: RecipientRole.SIGNER, signingOrder: 2 },
{ formId: 'b', role: RecipientRole.SIGNER, signingOrder: 5 },
{ formId: 'c', role: RecipientRole.SIGNER, signingOrder: 5 },
{ formId: 'd', role: RecipientRole.SIGNER, signingOrder: 9 },
];
expect(normalizeGroupedSigningOrders(recipients).map((r) => r.signingOrder)).toEqual([1, 2, 2, 3]);
});
it('moves CC recipients to the tail with an undefined signing order', () => {
const recipients = [
{ formId: 'cc', role: RecipientRole.CC, signingOrder: 1 },
{ formId: 'a', role: RecipientRole.SIGNER, signingOrder: 3 },
{ formId: 'b', role: RecipientRole.SIGNER, signingOrder: 3 },
];
const normalized = normalizeGroupedSigningOrders(recipients);
expect(normalized.map((r) => r.formId)).toEqual(['a', 'b', 'cc']);
expect(normalized.map((r) => r.signingOrder)).toEqual([1, 1, undefined]);
});
it('anchors steps containing locked recipients to their persisted order', () => {
const recipients = [
{ formId: 'locked', role: RecipientRole.SIGNER, signingOrder: 1 },
{ formId: 'a', role: RecipientRole.SIGNER, signingOrder: 4 },
{ formId: 'b', role: RecipientRole.SIGNER, signingOrder: 4 },
];
const normalized = normalizeGroupedSigningOrders(recipients, (r) => r.formId !== 'locked');
expect(normalized.map((r) => [r.formId, r.signingOrder])).toEqual([
['locked', 1],
['a', 2],
['b', 2],
]);
});
it('never renumbers an editable step onto a locked step number', () => {
const recipients = [
{ formId: 'a', role: RecipientRole.SIGNER, signingOrder: 1 },
{ formId: 'locked', role: RecipientRole.SIGNER, signingOrder: 2 },
{ formId: 'b', role: RecipientRole.SIGNER, signingOrder: 5 },
];
const normalized = normalizeGroupedSigningOrders(recipients, (r) => r.formId !== 'locked');
// 'b' must skip the reserved locked number 2 and take 3, not collide into 2.
expect(normalized.map((r) => [r.formId, r.signingOrder])).toEqual([
['a', 1],
['locked', 2],
['b', 3],
]);
});
it('keeps a group intact when it contains the locked recipient', () => {
const recipients = [
{ formId: 'locked', role: RecipientRole.SIGNER, signingOrder: 2 },
{ formId: 'peer', role: RecipientRole.SIGNER, signingOrder: 2 },
{ formId: 'a', role: RecipientRole.SIGNER, signingOrder: 7 },
];
const normalized = normalizeGroupedSigningOrders(recipients, (r) => r.formId !== 'locked');
expect(normalized.map((r) => [r.formId, r.signingOrder])).toEqual([
['locked', 2],
['peer', 2],
['a', 3],
]);
});
});
- Step 2: Run tests to verify they fail
Run: npm run test -w @documenso/lib -- utils/recipient-groups.test.ts
Expected: FAIL — cannot resolve ./recipient-groups.
- Step 3: Write the implementation
Create packages/lib/utils/recipient-groups.ts:
import type { Recipient } from '@prisma/client';
import { isCcRecipient } from './recipients';
/**
* A recipient "step" is the set of non-CC recipients sharing a signing order.
* A step with 2 or more members is a "signing group": members may act in any
* order among themselves, and the next step only unlocks once every member of
* the group has completed their action.
*/
type GroupableRecipient = Pick<Recipient, 'role'> & {
signingOrder?: number | null;
};
export type RecipientStep<T> = {
/**
* The signing order shared by all members of the step.
*/
order: number;
members: T[];
};
const UNORDERED = Number.MAX_SAFE_INTEGER;
const effectiveOrder = (recipient: { signingOrder?: number | null }) => recipient.signingOrder ?? UNORDERED;
/**
* Derives the ordered list of steps from a list of recipients.
*
* - Non-CC recipients sharing a signing order form one step.
* - Recipients without a signing order share a single tail step.
* - CC recipients are returned separately and never belong to a step.
*/
export const groupRecipientsBySigningOrder = <T extends GroupableRecipient>(recipients: T[]) => {
const ccRecipients = recipients.filter((recipient) => isCcRecipient(recipient));
const nonCcRecipients = recipients.filter((recipient) => !isCcRecipient(recipient));
const membersByOrder = new Map<number, T[]>();
for (const recipient of nonCcRecipients) {
const order = effectiveOrder(recipient);
const members = membersByOrder.get(order) ?? [];
members.push(recipient);
membersByOrder.set(order, members);
}
const steps: RecipientStep<T>[] = [...membersByOrder.entries()]
.sort(([orderA], [orderB]) => orderA - orderB)
.map(([order, members]) => ({ order, members }));
return { steps, ccRecipients };
};
/**
* Dense-renumbers steps to 1..K while preserving groups (duplicate orders).
*
* Steps containing a locked recipient (per `canUpdateRecipient`) keep the
* locked recipient's persisted order, and editable steps never collide into a
* locked step's number.
*
* CC recipients get an undefined signing order and move to the tail. The
* returned array is re-ordered by step sequence.
*/
export const normalizeGroupedSigningOrders = <T extends GroupableRecipient>(
recipients: T[],
canUpdateRecipient: (recipient: T) => boolean = () => true,
): Array<T & { signingOrder?: number }> => {
const { steps, ccRecipients } = groupRecipientsBySigningOrder(recipients);
const lockedOrderByStepIndex = new Map<number, number>();
steps.forEach((step, index) => {
const lockedMember = step.members.find((member) => !canUpdateRecipient(member));
if (lockedMember && typeof lockedMember.signingOrder === 'number') {
lockedOrderByStepIndex.set(index, lockedMember.signingOrder);
}
});
const reservedOrders = new Set(lockedOrderByStepIndex.values());
const normalizedSteps: RecipientStep<T>[] = [];
let nextOrder = 1;
steps.forEach((step, index) => {
const lockedOrder = lockedOrderByStepIndex.get(index);
if (lockedOrder !== undefined) {
normalizedSteps.push({ order: lockedOrder, members: step.members });
nextOrder = Math.max(nextOrder, lockedOrder + 1);
return;
}
while (reservedOrders.has(nextOrder)) {
nextOrder += 1;
}
normalizedSteps.push({ order: nextOrder, members: step.members });
nextOrder += 1;
});
return [
...normalizedSteps.flatMap((step) => step.members.map((member) => ({ ...member, signingOrder: step.order }))),
...ccRecipients.map((recipient) => ({ ...recipient, signingOrder: undefined })),
];
};
- Step 4: Run tests to verify they pass
Run: npm run test -w @documenso/lib -- utils/recipient-groups.test.ts
Expected: PASS (7 tests).
- Step 5: Commit
git add packages/lib/utils/recipient-groups.ts packages/lib/utils/recipient-groups.test.ts
git commit -m "feat: add recipient signing group derivation and normalization"
Task 2: Editor step operations
Files:
-
Modify:
packages/lib/utils/recipient-groups.ts(append) -
Modify:
packages/lib/utils/recipient-groups.test.ts(append) -
Step 1: Write the failing tests
Append to packages/lib/utils/recipient-groups.test.ts (add mergeSteps, moveRecipientToStep, extractRecipientToNewStep, reorderStep, ungroupStep to the existing import from ./recipient-groups):
const makeSigners = () => [
{ formId: 'a', role: RecipientRole.SIGNER, signingOrder: 1 },
{ formId: 'b', role: RecipientRole.SIGNER, signingOrder: 2 },
{ formId: 'c', role: RecipientRole.SIGNER, signingOrder: 3 },
{ formId: 'd', role: RecipientRole.SIGNER, signingOrder: 4 },
];
const ordersOf = (signers: Array<{ formId: string; signingOrder?: number }>) =>
signers.map((signer) => [signer.formId, signer.signingOrder]);
describe('mergeSteps', () => {
it('merges all members of the source step into the target step', () => {
const merged = mergeSteps(makeSigners(), 2, 1);
expect(ordersOf(merged)).toEqual([
['a', 1],
['b', 2],
['c', 2],
['d', 3],
]);
});
it('merges a whole group into another step', () => {
const signers = [
{ formId: 'a', role: RecipientRole.SIGNER, signingOrder: 1 },
{ formId: 'b', role: RecipientRole.SIGNER, signingOrder: 2 },
{ formId: 'c', role: RecipientRole.SIGNER, signingOrder: 2 },
{ formId: 'd', role: RecipientRole.SIGNER, signingOrder: 3 },
];
const merged = mergeSteps(signers, 1, 2);
expect(ordersOf(merged)).toEqual([
['a', 1],
['d', 2],
['b', 2],
['c', 2],
]);
});
it('returns the input unchanged for an invalid step index', () => {
const signers = makeSigners();
expect(mergeSteps(signers, 7, 1)).toEqual(signers);
});
});
describe('moveRecipientToStep', () => {
it('appends the recipient to the target step members', () => {
const moved = moveRecipientToStep(makeSigners(), 'a', 2);
expect(ordersOf(moved)).toEqual([
['b', 1],
['c', 2],
['a', 2],
['d', 3],
]);
});
it('dissolves a group of two when one member joins another step', () => {
const signers = [
{ formId: 'a', role: RecipientRole.SIGNER, signingOrder: 1 },
{ formId: 'b', role: RecipientRole.SIGNER, signingOrder: 1 },
{ formId: 'c', role: RecipientRole.SIGNER, signingOrder: 2 },
];
const moved = moveRecipientToStep(signers, 'b', 1);
expect(ordersOf(moved)).toEqual([
['a', 1],
['c', 2],
['b', 2],
]);
});
it('is a no-op when the recipient is already a member of the target step', () => {
const signers = makeSigners();
expect(ordersOf(moveRecipientToStep(signers, 'b', 1))).toEqual(ordersOf(signers));
});
});
describe('extractRecipientToNewStep', () => {
it('extracts a group member into its own step at the given gap', () => {
const signers = [
{ formId: 'a', role: RecipientRole.SIGNER, signingOrder: 1 },
{ formId: 'b', role: RecipientRole.SIGNER, signingOrder: 2 },
{ formId: 'c', role: RecipientRole.SIGNER, signingOrder: 2 },
{ formId: 'd', role: RecipientRole.SIGNER, signingOrder: 3 },
];
// Gap index 3 = before step index 3 does not exist here (only 3 steps), so
// use gap 2 = before the step containing 'd'.
const extracted = extractRecipientToNewStep(signers, 'c', 2);
expect(ordersOf(extracted)).toEqual([
['a', 1],
['b', 2],
['c', 3],
['d', 4],
]);
});
it('extracts to the end for an out-of-bounds gap index', () => {
const signers = [
{ formId: 'a', role: RecipientRole.SIGNER, signingOrder: 1 },
{ formId: 'b', role: RecipientRole.SIGNER, signingOrder: 1 },
{ formId: 'c', role: RecipientRole.SIGNER, signingOrder: 2 },
];
const extracted = extractRecipientToNewStep(signers, 'a', 99);
expect(ordersOf(extracted)).toEqual([
['b', 1],
['c', 2],
['a', 3],
]);
});
it('is a no-op when a solo recipient is dropped into an adjacent gap', () => {
const signers = makeSigners();
expect(ordersOf(extractRecipientToNewStep(signers, 'b', 1))).toEqual(ordersOf(signers));
expect(ordersOf(extractRecipientToNewStep(signers, 'b', 2))).toEqual(ordersOf(signers));
});
});
describe('reorderStep', () => {
it('moves a whole group to a new position', () => {
const signers = [
{ formId: 'a', role: RecipientRole.SIGNER, signingOrder: 1 },
{ formId: 'b', role: RecipientRole.SIGNER, signingOrder: 2 },
{ formId: 'c', role: RecipientRole.SIGNER, signingOrder: 2 },
{ formId: 'd', role: RecipientRole.SIGNER, signingOrder: 3 },
];
const reordered = reorderStep(signers, 1, 2);
expect(ordersOf(reordered)).toEqual([
['a', 1],
['d', 2],
['b', 3],
['c', 3],
]);
});
it('keeps a locked step number anchored while others flow around it', () => {
const signers = [
{ formId: 'locked', role: RecipientRole.SIGNER, signingOrder: 1 },
{ formId: 'b', role: RecipientRole.SIGNER, signingOrder: 2 },
{ formId: 'c', role: RecipientRole.SIGNER, signingOrder: 3 },
];
const reordered = reorderStep(signers, 1, 2, (r) => r.formId !== 'locked');
expect(ordersOf(reordered)).toEqual([
['locked', 1],
['c', 2],
['b', 3],
]);
});
});
describe('ungroupStep', () => {
it('splits a group into consecutive standalone steps preserving relative order', () => {
const signers = [
{ formId: 'a', role: RecipientRole.SIGNER, signingOrder: 1 },
{ formId: 'b', role: RecipientRole.SIGNER, signingOrder: 2 },
{ formId: 'c', role: RecipientRole.SIGNER, signingOrder: 2 },
{ formId: 'd', role: RecipientRole.SIGNER, signingOrder: 3 },
];
const ungrouped = ungroupStep(signers, 1);
expect(ordersOf(ungrouped)).toEqual([
['a', 1],
['b', 2],
['c', 3],
['d', 4],
]);
});
it('is a no-op on a step with a single member', () => {
const signers = makeSigners();
expect(ordersOf(ungroupStep(signers, 0))).toEqual(ordersOf(signers));
});
});
- Step 2: Run tests to verify they fail
Run: npm run test -w @documenso/lib -- utils/recipient-groups.test.ts
Expected: FAIL — mergeSteps (etc.) not exported.
- Step 3: Write the implementation
Append to packages/lib/utils/recipient-groups.ts:
type EditorRecipient = GroupableRecipient & { formId: string };
/**
* Merges all members of the source step into the target step.
*/
export const mergeSteps = <T extends EditorRecipient>(
recipients: T[],
sourceStepIndex: number,
targetStepIndex: number,
canUpdateRecipient?: (recipient: T) => boolean,
): Array<T & { signingOrder?: number }> => {
const { steps } = groupRecipientsBySigningOrder(recipients);
const sourceStep = steps[sourceStepIndex];
const targetStep = steps[targetStepIndex];
if (!sourceStep || !targetStep || sourceStepIndex === targetStepIndex) {
return recipients;
}
const sourceFormIds = new Set(sourceStep.members.map((member) => member.formId));
const updated = recipients.map((recipient) =>
sourceFormIds.has(recipient.formId) ? { ...recipient, signingOrder: targetStep.order } : recipient,
);
return normalizeGroupedSigningOrders(updated, canUpdateRecipient);
};
/**
* Moves a single recipient into the target step (joins the group).
*/
export const moveRecipientToStep = <T extends EditorRecipient>(
recipients: T[],
formId: string,
targetStepIndex: number,
canUpdateRecipient?: (recipient: T) => boolean,
): Array<T & { signingOrder?: number }> => {
const { steps } = groupRecipientsBySigningOrder(recipients);
const targetStep = steps[targetStepIndex];
const mover = recipients.find((recipient) => recipient.formId === formId);
if (!targetStep || !mover || isCcRecipient(mover)) {
return recipients;
}
if (targetStep.members.some((member) => member.formId === formId)) {
return recipients;
}
const remaining = recipients.filter((recipient) => recipient.formId !== formId);
const lastMemberFormId = targetStep.members[targetStep.members.length - 1].formId;
const insertAfterIndex = remaining.findIndex((recipient) => recipient.formId === lastMemberFormId);
const updated = [
...remaining.slice(0, insertAfterIndex + 1),
{ ...mover, signingOrder: targetStep.order },
...remaining.slice(insertAfterIndex + 1),
];
return normalizeGroupedSigningOrders(updated, canUpdateRecipient);
};
/**
* Extracts a recipient into its own standalone step at the given gap position
* (gap N sits before step N; an out-of-bounds gap appends to the end).
*/
export const extractRecipientToNewStep = <T extends EditorRecipient>(
recipients: T[],
formId: string,
insertStepIndex: number,
canUpdateRecipient?: (recipient: T) => boolean,
): Array<T & { signingOrder?: number }> => {
const { steps } = groupRecipientsBySigningOrder(recipients);
const mover = recipients.find((recipient) => recipient.formId === formId);
if (!mover || isCcRecipient(mover)) {
return recipients;
}
const currentStepIndex = steps.findIndex((step) => step.members.some((member) => member.formId === formId));
const isSoloStep = currentStepIndex !== -1 && steps[currentStepIndex].members.length === 1;
// Dropping a solo step into the gap directly above or below itself is a no-op.
if (isSoloStep && (insertStepIndex === currentStepIndex || insertStepIndex === currentStepIndex + 1)) {
return recipients;
}
const insertOrder =
insertStepIndex >= steps.length
? (steps[steps.length - 1]?.order ?? 0) + 1
: steps[insertStepIndex].order - 0.5;
const updated = recipients.map((recipient) =>
recipient.formId === formId ? { ...recipient, signingOrder: insertOrder } : recipient,
);
return normalizeGroupedSigningOrders(updated, canUpdateRecipient);
};
/**
* Moves a whole step (group) to a new position in the step sequence.
*
* Locked steps keep their members' persisted orders untouched (the sequence
* flows around them), and editable steps never collide onto a locked anchor —
* that would accidentally merge them during re-derivation.
*/
export const reorderStep = <T extends EditorRecipient>(
recipients: T[],
fromStepIndex: number,
toStepIndex: number,
canUpdateRecipient: (recipient: T) => boolean = () => true,
): Array<T & { signingOrder?: number }> => {
const { steps, ccRecipients } = groupRecipientsBySigningOrder(recipients);
if (!steps[fromStepIndex] || fromStepIndex === toStepIndex) {
return recipients;
}
const reorderedSteps = [...steps];
const [movedStep] = reorderedSteps.splice(fromStepIndex, 1);
reorderedSteps.splice(Math.min(toStepIndex, reorderedSteps.length), 0, movedStep);
const isStepLocked = (step: RecipientStep<T>) => step.members.some((member) => !canUpdateRecipient(member));
const lockedAnchors = new Set(reorderedSteps.filter((step) => isStepLocked(step)).map((step) => step.order));
const updated = [
...reorderedSteps.flatMap((step, index) => {
if (isStepLocked(step)) {
return step.members;
}
const tempOrder = lockedAnchors.has(index + 1) ? index + 1.5 : index + 1;
return step.members.map((member) => ({ ...member, signingOrder: tempOrder }));
}),
...ccRecipients,
];
return normalizeGroupedSigningOrders(updated, canUpdateRecipient);
};
/**
* Dissolves a group into consecutive standalone steps preserving relative order.
*/
export const ungroupStep = <T extends EditorRecipient>(
recipients: T[],
stepIndex: number,
canUpdateRecipient?: (recipient: T) => boolean,
): Array<T & { signingOrder?: number }> => {
const { steps } = groupRecipientsBySigningOrder(recipients);
const step = steps[stepIndex];
if (!step || step.members.length < 2) {
return recipients;
}
const offsetByFormId = new Map(step.members.map((member, index) => [member.formId, index]));
const updated = recipients.map((recipient) => {
const offset = offsetByFormId.get(recipient.formId);
if (offset === undefined) {
return recipient;
}
return { ...recipient, signingOrder: step.order + offset / (step.members.length + 1) };
});
return normalizeGroupedSigningOrders(updated, canUpdateRecipient);
};
- Step 4: Run tests to verify they pass
Run: npm run test -w @documenso/lib -- utils/recipient-groups.test.ts
Expected: PASS.
- Step 5: Commit
git add packages/lib/utils/recipient-groups.ts packages/lib/utils/recipient-groups.test.ts
git commit -m "feat: add signing group editor operations"
Task 3: Turn predicate, first-group filter, dictation helper
Files:
-
Modify:
packages/lib/utils/recipient-groups.ts(append) -
Modify:
packages/lib/utils/recipient-groups.test.ts(append) -
Step 1: Write the failing tests
Append to packages/lib/utils/recipient-groups.test.ts (extend the ./recipient-groups import with filterRecipientsInFirstSigningGroup, getDictatableNextRecipient, isRecipientTurnBySigningOrder; extend the @prisma/client import with SigningStatus):
describe('isRecipientTurnBySigningOrder', () => {
const recipient = (
id: number,
signingOrder: number | null,
signingStatus: SigningStatus,
role: RecipientRole = RecipientRole.SIGNER,
) => ({ id, signingOrder, signingStatus, role });
it('allows both members of the active group regardless of member order', () => {
const recipients = [
recipient(1, 1, SigningStatus.SIGNED),
recipient(2, 2, SigningStatus.NOT_SIGNED),
recipient(3, 2, SigningStatus.NOT_SIGNED),
recipient(4, 3, SigningStatus.NOT_SIGNED),
];
expect(isRecipientTurnBySigningOrder(recipients, recipients[1])).toBe(true);
expect(isRecipientTurnBySigningOrder(recipients, recipients[2])).toBe(true);
expect(isRecipientTurnBySigningOrder(recipients, recipients[3])).toBe(false);
});
it('blocks later steps until every group member has signed', () => {
const recipients = [
recipient(1, 1, SigningStatus.SIGNED),
recipient(2, 2, SigningStatus.SIGNED),
recipient(3, 2, SigningStatus.NOT_SIGNED),
recipient(4, 3, SigningStatus.NOT_SIGNED),
];
expect(isRecipientTurnBySigningOrder(recipients, recipients[3])).toBe(false);
});
it('treats a rejected recipient in an earlier step as blocking', () => {
const recipients = [
recipient(1, 1, SigningStatus.REJECTED),
recipient(2, 2, SigningStatus.NOT_SIGNED),
];
expect(isRecipientTurnBySigningOrder(recipients, recipients[1])).toBe(false);
});
it('ignores CC recipients entirely', () => {
const recipients = [
recipient(1, 1, SigningStatus.NOT_SIGNED, RecipientRole.CC),
recipient(2, 2, SigningStatus.NOT_SIGNED),
];
expect(isRecipientTurnBySigningOrder(recipients, recipients[1])).toBe(true);
});
it('treats recipients without a signing order as a parallel tail group', () => {
const recipients = [
recipient(1, 1, SigningStatus.SIGNED),
recipient(2, null, SigningStatus.NOT_SIGNED),
recipient(3, null, SigningStatus.NOT_SIGNED),
];
expect(isRecipientTurnBySigningOrder(recipients, recipients[1])).toBe(true);
expect(isRecipientTurnBySigningOrder(recipients, recipients[2])).toBe(true);
});
});
describe('filterRecipientsInFirstSigningGroup', () => {
it('returns every pending recipient sharing the lowest order', () => {
const pending = [
{ id: 3, signingOrder: 2 },
{ id: 4, signingOrder: 2 },
{ id: 5, signingOrder: 3 },
];
expect(filterRecipientsInFirstSigningGroup(pending).map((r) => r.id)).toEqual([3, 4]);
});
it('returns an empty array for no pending recipients', () => {
expect(filterRecipientsInFirstSigningGroup([])).toEqual([]);
});
});
describe('getDictatableNextRecipient', () => {
const recipient = (
id: number,
signingOrder: number | null,
signingStatus: SigningStatus,
role: RecipientRole = RecipientRole.SIGNER,
) => ({ id, signingOrder, signingStatus, role });
it('returns the next recipient when current is last of their step and next step is a single recipient', () => {
const recipients = [
recipient(1, 1, SigningStatus.SIGNED),
recipient(2, 2, SigningStatus.NOT_SIGNED),
recipient(3, 3, SigningStatus.NOT_SIGNED),
];
expect(getDictatableNextRecipient({ recipients, currentRecipientId: 2 })?.id).toBe(3);
});
it('returns null while a group peer is still unsigned', () => {
const recipients = [
recipient(1, 1, SigningStatus.NOT_SIGNED),
recipient(2, 1, SigningStatus.NOT_SIGNED),
recipient(3, 2, SigningStatus.NOT_SIGNED),
];
expect(getDictatableNextRecipient({ recipients, currentRecipientId: 1 })).toBeNull();
});
it('returns the next single recipient once all group peers signed', () => {
const recipients = [
recipient(1, 1, SigningStatus.SIGNED),
recipient(2, 1, SigningStatus.NOT_SIGNED),
recipient(3, 2, SigningStatus.NOT_SIGNED),
];
expect(getDictatableNextRecipient({ recipients, currentRecipientId: 2 })?.id).toBe(3);
});
it('returns null when the next step is a group', () => {
const recipients = [
recipient(1, 1, SigningStatus.NOT_SIGNED),
recipient(2, 2, SigningStatus.NOT_SIGNED),
recipient(3, 2, SigningStatus.NOT_SIGNED),
];
expect(getDictatableNextRecipient({ recipients, currentRecipientId: 1 })).toBeNull();
});
it('returns null when there is no later step, for CC targets, or unknown recipients', () => {
const recipients = [
recipient(1, 1, SigningStatus.NOT_SIGNED),
recipient(2, null, SigningStatus.NOT_SIGNED, RecipientRole.CC),
];
expect(getDictatableNextRecipient({ recipients, currentRecipientId: 1 })).toBeNull();
expect(getDictatableNextRecipient({ recipients, currentRecipientId: 999 })).toBeNull();
});
});
- Step 2: Run tests to verify they fail
Run: npm run test -w @documenso/lib -- utils/recipient-groups.test.ts
Expected: FAIL — missing exports.
- Step 3: Write the implementation
Append to packages/lib/utils/recipient-groups.ts (add SigningStatus to a new value import: import { SigningStatus } from '@prisma/client'; at the top, keeping the existing type import):
type SignableRecipient = Pick<Recipient, 'role' | 'signingStatus'> & {
signingOrder?: number | null;
};
/**
* Whether it is the recipient's turn to act under SEQUENTIAL signing.
*
* A recipient may act iff no non-CC recipient with a strictly lower signing
* order is still unsigned (rejected counts as unsigned/blocking). Recipients
* sharing a signing order never block each other.
*
* Callers are responsible for checking the document is in SEQUENTIAL mode.
*/
export const isRecipientTurnBySigningOrder = <T extends SignableRecipient>(
recipients: T[],
currentRecipient: { signingOrder?: number | null },
): boolean => {
const currentOrder = effectiveOrder(currentRecipient);
return !recipients.some(
(recipient) =>
!isCcRecipient(recipient) &&
recipient.signingStatus !== SigningStatus.SIGNED &&
effectiveOrder(recipient) < currentOrder,
);
};
/**
* Returns every pending recipient sharing the lowest pending signing order —
* the "active group". Callers pass an already-filtered pending list.
*/
export const filterRecipientsInFirstSigningGroup = <T extends { signingOrder?: number | null }>(
pendingRecipients: T[],
): T[] => {
if (pendingRecipients.length === 0) {
return [];
}
const minOrder = Math.min(...pendingRecipients.map((recipient) => effectiveOrder(recipient)));
return pendingRecipients.filter((recipient) => effectiveOrder(recipient) === minOrder);
};
/**
* The single recipient that the current recipient may dictate (rename) on
* completion, or null when dictation does not apply:
*
* - the current recipient must be the last unsigned member of their step, and
* - the next step must contain exactly one recipient.
*/
export const getDictatableNextRecipient = <T extends SignableRecipient & Pick<Recipient, 'id'>>({
recipients,
currentRecipientId,
}: {
recipients: T[];
currentRecipientId: number;
}): T | null => {
const currentRecipient = recipients.find((recipient) => recipient.id === currentRecipientId);
if (!currentRecipient || isCcRecipient(currentRecipient)) {
return null;
}
const currentOrder = effectiveOrder(currentRecipient);
const hasUnsignedPeers = recipients.some(
(recipient) =>
recipient.id !== currentRecipientId &&
!isCcRecipient(recipient) &&
effectiveOrder(recipient) === currentOrder &&
recipient.signingStatus !== SigningStatus.SIGNED,
);
if (hasUnsignedPeers) {
return null;
}
const laterRecipients = recipients.filter(
(recipient) => !isCcRecipient(recipient) && effectiveOrder(recipient) > currentOrder,
);
const nextStep = filterRecipientsInFirstSigningGroup(laterRecipients);
if (nextStep.length !== 1) {
return null;
}
return nextStep[0];
};
- Step 4: Run tests to verify they pass
Run: npm run test -w @documenso/lib -- utils/recipient-groups.test.ts
Expected: PASS.
- Step 5: Commit
git add packages/lib/utils/recipient-groups.ts packages/lib/utils/recipient-groups.test.ts
git commit -m "feat: add group-aware turn and dictation helpers"
Task 4: Group-aware isAssistantLastSigner
Files:
-
Modify:
packages/lib/utils/recipients.ts:25-30 -
Modify:
packages/lib/utils/recipients.test.ts -
Step 1: Write the failing tests
Append inside the existing describe in packages/lib/utils/recipients.test.ts:
it('detects an assistant anywhere in the last signing step (groups)', () => {
expect(
isAssistantLastSigner([
{ role: RecipientRole.SIGNER, signingOrder: 1 },
{ role: RecipientRole.ASSISTANT, signingOrder: 2 },
{ role: RecipientRole.SIGNER, signingOrder: 2 },
]),
).toBe(true);
expect(
isAssistantLastSigner([
{ role: RecipientRole.ASSISTANT, signingOrder: 1 },
{ role: RecipientRole.SIGNER, signingOrder: 1 },
{ role: RecipientRole.SIGNER, signingOrder: 2 },
]),
).toBe(false);
});
- Step 2: Run tests to verify they fail
Run: npm run test -w @documenso/lib -- utils/recipients.test.ts
Expected: FAIL — first assertion returns false (current implementation checks the last array element; the assistant with order 2 is not last in the array).
- Step 3: Replace the implementation
In packages/lib/utils/recipients.ts, replace the existing isAssistantLastSigner (lines 25-30) with:
/**
* Whether an assistant sits in the last signing step (nobody after them to assist).
*
* Falls back to a positional check when no recipient carries a signing order.
*/
export const isAssistantLastSigner = (
recipients: Array<Pick<Recipient, 'role'> & { signingOrder?: number | null }>,
) => {
const nonCcRecipients = recipients.filter((recipient) => !isCcRecipient(recipient));
if (nonCcRecipients.length === 0) {
return false;
}
const hasAnySigningOrder = nonCcRecipients.some((recipient) => typeof recipient.signingOrder === 'number');
if (!hasAnySigningOrder) {
return nonCcRecipients[nonCcRecipients.length - 1]?.role === RecipientRole.ASSISTANT;
}
const maxOrder = Math.max(
...nonCcRecipients.map((recipient) => recipient.signingOrder ?? Number.MAX_SAFE_INTEGER),
);
return nonCcRecipients.some(
(recipient) =>
(recipient.signingOrder ?? Number.MAX_SAFE_INTEGER) === maxOrder &&
recipient.role === RecipientRole.ASSISTANT,
);
};
- Step 4: Run tests to verify they pass
Run: npm run test -w @documenso/lib -- utils/recipients.test.ts
Expected: PASS — both the new test and the two existing positional tests (checks whether the last non-CC recipient is an assistant).
- Step 5: Commit
git add packages/lib/utils/recipients.ts packages/lib/utils/recipients.test.ts
git commit -m "feat: make assistant-last-signer check signing-group aware"
Task 5: Server turn checks use the shared predicate
Files:
-
Modify:
packages/lib/server-only/recipient/get-is-recipient-turn.ts -
Modify:
packages/lib/server-only/envelope/get-envelope-for-recipient-signing.ts:263-279 -
Step 1: Rewrite
get-is-recipient-turn.ts
Replace the entire file content with:
import { prisma } from '@documenso/prisma';
import { DocumentSigningOrder, EnvelopeType } from '@prisma/client';
import { isRecipientTurnBySigningOrder } from '../../utils/recipient-groups';
export type GetIsRecipientTurnOptions = {
token: string;
};
export async function getIsRecipientsTurnToSign({ token }: GetIsRecipientTurnOptions) {
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
type: EnvelopeType.DOCUMENT,
recipients: {
some: {
token,
},
},
},
include: {
documentMeta: true,
recipients: true,
},
});
if (envelope.documentMeta?.signingOrder !== DocumentSigningOrder.SEQUENTIAL) {
return true;
}
const currentRecipient = envelope.recipients.find((recipient) => recipient.token === token);
if (!currentRecipient) {
return false;
}
return isRecipientTurnBySigningOrder(envelope.recipients, currentRecipient);
}
- Step 2: Replace the inline copy in
get-envelope-for-recipient-signing.ts
Replace lines 263-279 (the let isRecipientsTurn = true; block through the closing brace of the for loop's if) with:
const isRecipientsTurn =
envelope.documentMeta.signingOrder !== DocumentSigningOrder.SEQUENTIAL ||
isRecipientTurnBySigningOrder(envelope.recipients, recipient);
Add the import:
import { isRecipientTurnBySigningOrder } from '../../utils/recipient-groups';
Also remove the now-unneeded orderBy: { signingOrder: 'asc' } on the recipients include (lines 197-199) — the predicate is order-independent. Then search the file for RecipientRole — if it is no longer referenced after this change, remove it from the @prisma/client import (keep SigningStatus, still used at lines 296-297).
- Step 3: Type-check
Run: npx tsc --noEmit (workdir packages/lib)
Expected: no errors in the two modified files.
- Step 4: Commit
git add packages/lib/server-only/recipient/get-is-recipient-turn.ts packages/lib/server-only/envelope/get-envelope-for-recipient-signing.ts
git commit -m "feat: use group-aware turn checks for sequential signing"
Task 6: send-document.ts notifies the whole first group
Files:
-
Modify:
packages/lib/server-only/document/send-document.ts:150-157 -
Step 1: Replace the single-recipient slice
Replace:
let recipientsToNotify = envelope.recipients;
if (signingOrder === DocumentSigningOrder.SEQUENTIAL) {
// Get the currently active recipient.
recipientsToNotify = envelope.recipients
.filter((r) => r.signingStatus === SigningStatus.NOT_SIGNED && r.role !== RecipientRole.CC)
.slice(0, 1);
}
with:
let recipientsToNotify = envelope.recipients;
if (signingOrder === DocumentSigningOrder.SEQUENTIAL) {
// Get the currently active signing group. Recipients sharing the lowest
// pending signing order act in parallel within their group.
recipientsToNotify = filterRecipientsInFirstSigningGroup(
envelope.recipients.filter((r) => r.signingStatus === SigningStatus.NOT_SIGNED && r.role !== RecipientRole.CC),
);
}
Add the import:
import { filterRecipientsInFirstSigningGroup } from '../../utils/recipient-groups';
- Step 2: Type-check
Run: npx tsc --noEmit (workdir packages/lib)
Expected: no new errors.
- Step 3: Commit
git add packages/lib/server-only/document/send-document.ts
git commit -m "feat: notify all members of the first signing group on send"
Task 7: complete-document-with-token.ts group advance + dictation gating
Files:
-
Modify:
packages/lib/server-only/document/complete-document-with-token.ts:368-460 -
Step 1: Add
sendStatusto the pending query select
In the pendingRecipients query (lines 368-388), extend the select:
select: {
id: true,
signingOrder: true,
name: true,
email: true,
role: true,
sendStatus: true,
},
- Step 2: Replace the SEQUENTIAL advance branch
Replace the block from if (envelope.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL) { (line 399) through its closing brace (line 459) with:
if (envelope.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL) {
// The active group: every pending recipient sharing the lowest pending
// signing order. Members already activated (sendStatus SENT) are group
// peers who were notified earlier — activating only fresh members means
// a mid-group completion is a no-op and a step transition activates the
// whole next group at once.
const nextGroup = filterRecipientsInFirstSigningGroup(pendingRecipients);
const recipientsToActivate = nextGroup.filter((r) => r.sendStatus !== SendStatus.SENT);
// Dictation only applies when advancing to a single-recipient step.
const canDictateNextSigner =
Boolean(nextSigner) &&
Boolean(envelope.documentMeta?.allowDictateNextSigner) &&
nextGroup.length === 1 &&
recipientsToActivate.length === 1;
await prisma.$transaction(async (tx) => {
if (canDictateNextSigner && nextSigner) {
const [nextRecipient] = recipientsToActivate;
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
envelopeId: envelope.id,
user: {
name: recipientName,
email: recipientEmail,
},
requestMetadata,
data: {
recipientEmail: nextRecipient.email,
recipientName: nextRecipient.name,
recipientId: nextRecipient.id,
recipientRole: nextRecipient.role,
changes: [
{
type: RECIPIENT_DIFF_TYPE.NAME,
from: nextRecipient.name,
to: nextSigner.name,
},
{
type: RECIPIENT_DIFF_TYPE.EMAIL,
from: nextRecipient.email,
to: nextSigner.email,
},
],
},
}),
});
}
for (const nextRecipient of recipientsToActivate) {
await tx.recipient.update({
where: { id: nextRecipient.id },
data: {
sendStatus: SendStatus.SENT,
sentAt: new Date(),
...(canDictateNextSigner && nextSigner
? {
name: nextSigner.name,
email: nextSigner.email,
}
: {}),
},
});
}
});
for (const nextRecipient of recipientsToActivate) {
await jobs.triggerJob({
name: 'send.signing.requested.email',
payload: {
userId: envelope.userId,
documentId: legacyDocumentId,
recipientId: nextRecipient.id,
requestMetadata,
},
});
}
}
Add the import:
import { filterRecipientsInFirstSigningGroup } from '../../utils/recipient-groups';
(SendStatus is already imported in this file.)
- Step 3: Type-check
Run: npx tsc --noEmit (workdir packages/lib)
Expected: no new errors.
- Step 4: Commit
git add packages/lib/server-only/document/complete-document-with-token.ts
git commit -m "feat: activate whole signing group on sequential advance"
Task 8: Direct-template dictation + getNextPendingRecipient
Files:
-
Modify:
packages/lib/server-only/template/create-document-from-direct-template.ts:697 -
Modify:
packages/lib/server-only/recipient/get-next-pending-recipient.ts -
Step 1: Gate direct-template dictation to single-member next steps
In create-document-from-direct-template.ts, replace line 697:
const nextRecipient = pendingRecipients[0];
with:
const nextGroup = filterRecipientsInFirstSigningGroup(pendingRecipients);
// Dictation only applies when the next step is a single recipient.
const nextRecipient = nextGroup.length === 1 ? nextGroup[0] : null;
Add the import:
import { filterRecipientsInFirstSigningGroup } from '../../utils/recipient-groups';
(The following if (nextRecipient) { ... } block stays unchanged.)
- Step 2: Rewrite
get-next-pending-recipient.ts
Replace the entire file content with:
import { prisma } from '@documenso/prisma';
import { EnvelopeType } from '@prisma/client';
import { mapDocumentIdToSecondaryId } from '../../utils/envelope';
import { getDictatableNextRecipient } from '../../utils/recipient-groups';
export const getNextPendingRecipient = async ({
documentId,
currentRecipientId,
}: {
documentId: number;
currentRecipientId: number;
}) => {
const recipients = await prisma.recipient.findMany({
where: {
envelope: {
type: EnvelopeType.DOCUMENT,
secondaryId: mapDocumentIdToSecondaryId(documentId),
},
},
});
const nextRecipient = getDictatableNextRecipient({ recipients, currentRecipientId });
if (!nextRecipient) {
return null;
}
return {
...nextRecipient,
token: '',
};
};
- Step 3: Type-check
Run: npx tsc --noEmit (workdir packages/lib)
Expected: no new errors.
- Step 4: Commit
git add packages/lib/server-only/template/create-document-from-direct-template.ts packages/lib/server-only/recipient/get-next-pending-recipient.ts
git commit -m "feat: gate next-signer dictation to single-recipient steps"
Task 9: Assistant scope becomes strictly-greater (self preserved)
Files:
- Modify:
packages/lib/server-only/recipient/get-recipients-for-assistant.ts:23-29 - Modify:
packages/trpc/server/envelope-router/sign-envelope-field.ts:42-51
Grouped assistants must not act for their group peers (equal order). The current gte queries also match the assistant themself — that self-inclusion is load-bearing (assistants insert their own fields through these paths) and must be preserved explicitly.
- Step 1: Update
get-recipients-for-assistant.ts
Replace the where clause of the recipients query (lines 24-29):
where: {
envelopeId: assistant.envelopeId,
OR: [
// The assistant themself — they may have fields of their own.
{ id: assistant.id },
// Grouped assistants only assist strictly later steps, never their
// own group peers.
{ signingOrder: { gt: assistant.signingOrder ?? 0 } },
],
},
- Step 2: Update the assistant branch in
sign-envelope-field.ts
Replace the assistant branch of the field.recipient condition (lines 42-51):
...(recipient.role === RecipientRole.ASSISTANT
? {
signingStatus: {
not: SigningStatus.SIGNED,
},
envelopeId: recipient.envelopeId,
OR: [
// The assistant's own fields.
{ id: recipient.id },
// Fields of recipients in strictly later steps only.
{ signingOrder: { gt: recipient.signingOrder ?? 0 } },
],
}
: {
id: recipient.id,
}),
- Step 3: Type-check both packages
Run: npx tsc --noEmit (workdir packages/lib), then npx tsc --noEmit (workdir packages/trpc)
Expected: no new errors.
- Step 4: Commit
git add packages/lib/server-only/recipient/get-recipients-for-assistant.ts packages/trpc/server/envelope-router/sign-envelope-field.ts
git commit -m "feat: scope assistants to strictly later signing steps"
Task 10: Client dictation mirrors use getDictatableNextRecipient
Files:
- Modify:
apps/remix/app/components/general/document-signing/envelope-signing-provider.tsx:292-319 - Modify:
apps/remix/app/components/general/document-signing/document-signing-page-view-v1.tsx:145-171 - Modify:
apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx:217-247
All three files contain a near-identical nextRecipient useMemo (sort by order nulls-last then id, return sortedRecipients[currentIndex + 1]). Replace each body with the shared helper — the dictate form then only renders when the signer is the last unsigned member of their step and the next step has exactly one member.
- Step 1:
envelope-signing-provider.tsx
Replace the nextRecipient useMemo (lines 292-319) with:
const nextRecipient = useMemo(() => {
if (envelope.documentMeta.signingOrder !== 'SEQUENTIAL') {
return null;
}
return getDictatableNextRecipient({
recipients: envelope.recipients,
currentRecipientId: recipient.id,
});
}, [envelope.documentMeta?.signingOrder, envelope.recipients, recipient.id]);
Add the import:
import { getDictatableNextRecipient } from '@documenso/lib/utils/recipient-groups';
- Step 2:
document-signing-page-view-v1.tsx
Replace the nextRecipient useMemo (lines 145-171) with:
const nextRecipient = useMemo(() => {
if (documentMeta?.signingOrder !== 'SEQUENTIAL') {
return undefined;
}
return getDictatableNextRecipient({ recipients: allRecipients, currentRecipientId: recipient.id }) ?? undefined;
}, [document.documentMeta?.signingOrder, allRecipients, recipient.id]);
Add the same import.
- Step 3:
direct-template-signing-form.tsx
Open the file and locate the nextRecipient useMemo (lines 217-247). Keep the existing early-return guard exactly as-is (it checks template.templateMeta?.signingOrder !== 'SEQUENTIAL' plus direct-recipient conditions at lines 218-225) and replace only the sorting/index logic below it with:
return (
getDictatableNextRecipient({
recipients: template.recipients,
currentRecipientId: directRecipient.id,
}) ?? undefined
);
Keep the dependency array unchanged. Add the same import.
- Step 4: Type-check
Run: npx tsc --noEmit (workdir apps/remix)
Expected: no new errors in the three modified files. If getDictatableNextRecipient's return type narrows the previous local type, keep the surrounding usages (nextRecipient.name / nextRecipient.email) working — the helper returns the full recipient object from the input array, so no shape changes.
- Step 5: Commit
git add apps/remix/app/components/general/document-signing/envelope-signing-provider.tsx apps/remix/app/components/general/document-signing/document-signing-page-view-v1.tsx apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx
git commit -m "feat: gate dictation UI to single-recipient next steps"
Task 11: Editor lib support — grouped load, CSC validation, modifiability util
Files:
-
Modify:
packages/lib/client-only/hooks/use-editor-recipients.ts -
Modify:
packages/lib/utils/recipients.ts(append) -
Step 1: Load recipients with grouped normalization
In use-editor-recipients.ts line 104, the flat normalizer destroys persisted groups on load. Replace:
? normalizeRecipientSigningOrders(sortRecipientsForSigningOrder(formRecipients))
with:
? normalizeGroupedSigningOrders(sortRecipientsForSigningOrder(formRecipients))
Update the import at line 12: replace normalizeRecipientSigningOrders with normalizeGroupedSigningOrders from ../../utils/recipient-groups (keep isCcRecipient and sortRecipientsForSigningOrder from ../../utils/recipients):
import { normalizeGroupedSigningOrders } from '../../utils/recipient-groups';
import { isCcRecipient, sortRecipientsForSigningOrder } from '../../utils/recipients';
- Step 2: Add the CSC no-duplicates rule
In the superRefine of ZEditorRecipientsFormSchema (after the existing assistant-role loop, before the closing });), append:
const seenSigningOrders = new Set<number>();
data.signers.forEach((signer, index) => {
if (signer.role === RecipientRole.CC || typeof signer.signingOrder !== 'number') {
return;
}
if (seenSigningOrders.has(signer.signingOrder)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'CSC envelopes do not support recipient signing groups.',
path: ['signers', index, 'signingOrder'],
});
}
seenSigningOrders.add(signer.signingOrder);
});
- Step 3: Add
canEditorRecipientBeModifiedtorecipients.ts
Append to packages/lib/utils/recipients.ts (add EnvelopeType to the @prisma/client value import and add import type { TEditorEnvelope } from '../types/envelope-editor';):
/**
* Editor-level wrapper around `canRecipientBeModified`.
*
* Template recipients and unsaved (id-less) recipients can always be modified.
*/
export const canEditorRecipientBeModified = (
envelope: Pick<TEditorEnvelope, 'type' | 'recipients' | 'fields'>,
recipientId?: number,
) => {
if (envelope.type === EnvelopeType.TEMPLATE) {
return true;
}
if (recipientId === undefined) {
return true;
}
const recipient = envelope.recipients.find((r) => r.id === recipientId);
if (!recipient) {
return false;
}
return canRecipientBeModified(recipient, envelope.fields);
};
- Step 4: Type-check and run lib tests
Run: npx tsc --noEmit (workdir packages/lib), then npm run test -w @documenso/lib
Expected: no errors, all tests pass.
- Step 5: Commit
git add packages/lib/client-only/hooks/use-editor-recipients.ts packages/lib/utils/recipients.ts
git commit -m "feat: load editor recipients with group-preserving normalization"
Task 12: RecipientRow component
Files:
- Create:
apps/remix/app/components/general/envelope-editor/recipient-row.tsx
This is the row markup extracted from envelope-editor-recipient-form.tsx:845-1065, parameterized. No tests of its own (covered by E2E + tsc); the form keeps compiling because it is not rewired until Task 15.
- Step 1: Create the component
import type { TEditorRecipientsFormSchema } from '@documenso/lib/client-only/hooks/use-editor-recipients';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { isCcRecipient } from '@documenso/lib/utils/recipients';
import { RecipientActionAuthSelect } from '@documenso/ui/components/recipient/recipient-action-auth-select';
import {
RecipientAutoCompleteInput,
type RecipientAutoCompleteOption,
} from '@documenso/ui/components/recipient/recipient-autocomplete-input';
import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { FormControl, FormField, FormItem, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import type { DraggableProvidedDragHandleProps } from '@hello-pangea/dnd';
import { useLingui } from '@lingui/react/macro';
import { EnvelopeType, type RecipientRole } from '@prisma/client';
import { GripVerticalIcon, TrashIcon } from 'lucide-react';
import { useFormContext } from 'react-hook-form';
type TEditorSigner = TEditorRecipientsFormSchema['signers'][number];
export type RecipientRowProps = {
signerIndex: number;
signer: TEditorSigner;
stepCount: number;
isSequential: boolean;
isGrouped: boolean;
isInputDisabled: boolean;
canBeModified: boolean;
isRemoveDisabled: boolean;
showAdvancedSettings: boolean;
dragHandleProps?: DraggableProvidedDragHandleProps | null;
recipientSuggestions: RecipientAutoCompleteOption[];
isLoadingSuggestions: boolean;
onSigningOrderChange: (signerIndex: number, value: string) => void;
onRoleChange: (signerIndex: number, role: RecipientRole) => void;
onRemove: (signerIndex: number) => void;
onAutoCompleteSelect: (signerIndex: number, suggestion: RecipientAutoCompleteOption) => void;
onSearchQueryChange: (query: string) => void;
};
export const RecipientRow = ({
signerIndex,
signer,
stepCount,
isSequential,
isGrouped,
isInputDisabled,
canBeModified,
isRemoveDisabled,
showAdvancedSettings,
dragHandleProps,
recipientSuggestions,
isLoadingSuggestions,
onSigningOrderChange,
onRoleChange,
onRemove,
onAutoCompleteSelect,
onSearchQueryChange,
}: RecipientRowProps) => {
const { t } = useLingui();
const { envelope, editorConfig } = useCurrentEnvelopeEditor();
const organisation = useCurrentOrganisation();
const form = useFormContext<TEditorRecipientsFormSchema>();
const { isSubmitting } = form.formState;
const isDirectRecipient =
envelope.type === EnvelopeType.TEMPLATE &&
envelope.directLink !== null &&
signer.id === envelope.directLink.directTemplateRecipientId;
const isFieldDisabled = isInputDisabled || isSubmitting || !canBeModified;
const rowErrors = form.formState.errors.signers?.[signerIndex];
return (
<fieldset data-native-id={signer.id} disabled={isSubmitting || !canBeModified} className="py-1">
<div className="flex flex-row items-center gap-x-2">
{isSequential && !isCcRecipient(signer) && (
<FormField
control={form.control}
name={`signers.${signerIndex}.signingOrder`}
render={({ field }) => (
<FormItem
className={cn('mt-auto flex items-center gap-x-1 space-y-0', {
'mb-6': rowErrors && !rowErrors.signingOrder,
})}
>
<span {...(dragHandleProps ?? {})} data-testid="recipient-row-drag-handle">
<GripVerticalIcon
className={cn('h-5 w-5 flex-shrink-0 opacity-40', {
'opacity-10': !dragHandleProps,
})}
/>
</span>
<FormControl>
<Input
type="number"
min={1}
max={stepCount + 1}
data-testid="signing-order-input"
className={cn(
'w-10 text-center',
'[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none',
{
'border-primary/50 bg-primary/5': isGrouped,
},
)}
{...field}
onChange={(e) => {
field.onChange(e);
onSigningOrderChange(signerIndex, e.target.value);
}}
onBlur={(e) => {
field.onBlur();
onSigningOrderChange(signerIndex, e.target.value);
}}
disabled={isFieldDisabled}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name={`signers.${signerIndex}.email`}
render={({ field }) => (
<FormItem
className={cn('relative w-full', {
'mb-6': rowErrors && !rowErrors.email,
})}
>
<FormControl>
<RecipientAutoCompleteInput
type="email"
placeholder={t`Email`}
value={field.value}
disabled={isFieldDisabled || isDirectRecipient}
options={recipientSuggestions}
onSelect={(suggestion) => onAutoCompleteSelect(signerIndex, suggestion)}
onSearchQueryChange={(query) => {
field.onChange(query);
onSearchQueryChange(query);
}}
loading={isLoadingSuggestions}
data-testid="signer-email-input"
maxLength={254}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`signers.${signerIndex}.name`}
render={({ field }) => (
<FormItem
className={cn('w-full', {
'mb-6': rowErrors && !rowErrors.name,
})}
>
<FormControl>
<RecipientAutoCompleteInput
type="text"
placeholder={t`Recipient ${signerIndex + 1}`}
{...field}
disabled={isFieldDisabled || isDirectRecipient}
options={recipientSuggestions}
onSelect={(suggestion) => onAutoCompleteSelect(signerIndex, suggestion)}
onSearchQueryChange={(query) => {
field.onChange(query);
onSearchQueryChange(query);
}}
loading={isLoadingSuggestions}
maxLength={255}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`signers.${signerIndex}.role`}
render={({ field }) => (
<FormItem
className={cn('mt-auto w-fit', {
'mb-6': rowErrors && !rowErrors.role,
})}
>
<FormControl>
<RecipientRoleSelect
{...field}
hideAssistantRole={!editorConfig.recipients?.allowAssistantRole}
hideCCerRole={!editorConfig.recipients?.allowCCerRole}
hideViewerRole={!editorConfig.recipients?.allowViewerRole}
hideApproverRole={!editorConfig.recipients?.allowApproverRole}
isAssistantEnabled={isSequential}
onValueChange={(value) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
onRoleChange(signerIndex, value as RecipientRole);
}}
disabled={isFieldDisabled}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
variant="ghost"
className={cn('mt-auto px-2', {
'mb-6': rowErrors,
})}
data-testid="remove-signer-button"
disabled={isFieldDisabled || isRemoveDisabled || isDirectRecipient}
onClick={() => onRemove(signerIndex)}
>
<TrashIcon className="h-4 w-4" />
</Button>
</div>
{showAdvancedSettings && organisation.organisationClaim.flags.cfr21 && (
<FormField
control={form.control}
name={`signers.${signerIndex}.actionAuth`}
render={({ field }) => (
<FormItem
className={cn('mt-2 w-full', {
'mb-6': rowErrors && !rowErrors.actionAuth,
'pl-6': isSequential,
})}
>
<FormControl>
<RecipientActionAuthSelect {...field} onValueChange={field.onChange} disabled={isFieldDisabled} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</fieldset>
);
};
Notes vs the original markup: the motion.fieldset becomes a plain fieldset (it carried no animation props); the first-row Email/Name labels move to the list header (Task 14); the CC spacer div is dropped (CC rows render in their own card without an order column).
- Step 2: Type-check
Run: npx tsc --noEmit (workdir apps/remix)
Expected: no errors (component is not yet imported anywhere).
- Step 3: Commit
git add apps/remix/app/components/general/envelope-editor/recipient-row.tsx
git commit -m "feat: extract envelope editor recipient row component"
Task 13: RecipientStepCard component
Files:
-
Create:
apps/remix/app/components/general/envelope-editor/recipient-step-card.tsx -
Step 1: Create the component
import type { TEditorRecipientsFormSchema } from '@documenso/lib/client-only/hooks/use-editor-recipients';
import type { RecipientStep } from '@documenso/lib/utils/recipient-groups';
import { cn } from '@documenso/ui/lib/utils';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import type { DraggableProvided, DraggableStateSnapshot } from '@hello-pangea/dnd';
import { Draggable, Droppable } from '@hello-pangea/dnd';
import { Trans } from '@lingui/react/macro';
import { GripVerticalIcon, Users2Icon } from 'lucide-react';
import { RecipientRow, type RecipientRowProps } from './recipient-row';
type TEditorSigner = TEditorRecipientsFormSchema['signers'][number];
export type DraggingType = 'STEP' | 'RECIPIENT' | null;
export type RecipientStepCardSharedRowProps = Pick<
RecipientRowProps,
| 'stepCount'
| 'showAdvancedSettings'
| 'recipientSuggestions'
| 'isLoadingSuggestions'
| 'onSigningOrderChange'
| 'onRoleChange'
| 'onRemove'
| 'onAutoCompleteSelect'
| 'onSearchQueryChange'
>;
export type RecipientStepCardProps = {
stepIndex: number;
step: RecipientStep<TEditorSigner>;
draggableProvided: DraggableProvided;
draggableSnapshot: DraggableStateSnapshot;
draggingType: DraggingType;
isStepLocked: boolean;
isRemoveDisabled: boolean;
flatIndexByFormId: Map<string, number>;
canSignerBeModified: (signer: TEditorSigner) => boolean;
isSubmitting: boolean;
onUngroup: (stepIndex: number) => void;
rowProps: RecipientStepCardSharedRowProps;
};
export const RecipientStepCard = ({
stepIndex,
step,
draggableProvided,
draggableSnapshot,
draggingType,
isStepLocked,
isRemoveDisabled,
flatIndexByFormId,
canSignerBeModified,
isSubmitting,
onUngroup,
rowProps,
}: RecipientStepCardProps) => {
const isGroup = step.members.length > 1;
const isCombineTarget = draggingType === 'STEP' && Boolean(draggableSnapshot.combineTargetFor);
return (
<div
ref={draggableProvided.innerRef}
{...draggableProvided.draggableProps}
className={cn('py-1', {
'pointer-events-none': draggableSnapshot.isDragging,
})}
>
<Droppable
droppableId={`step-members-${stepIndex}`}
type="RECIPIENT"
isDropDisabled={draggingType !== 'RECIPIENT'}
>
{(droppableProvided, droppableSnapshot) => {
const isJoinTarget = draggingType === 'RECIPIENT' && droppableSnapshot.isDraggingOver;
const isHighlighted = isCombineTarget || isJoinTarget;
return (
<div
ref={droppableProvided.innerRef}
{...droppableProvided.droppableProps}
data-testid="recipient-step-card"
className={cn('relative rounded-lg border px-3 pb-1 pt-2', {
'border-primary/60 bg-primary/5': isGroup,
'bg-widget-foreground': draggableSnapshot.isDragging,
'border-primary ring-1 ring-primary': isHighlighted,
})}
>
{isHighlighted && (
<Badge
variant="default"
size="small"
className="absolute -top-3 right-4 z-10 flex items-center gap-x-1 shadow-sm"
>
<Users2Icon className="h-3 w-3" />
<Trans>Release to sign together</Trans>
</Badge>
)}
<div className="flex flex-row items-center gap-x-2">
<span
{...(draggableProvided.dragHandleProps ?? {})}
data-testid="step-drag-handle"
className={cn({ 'pointer-events-none opacity-30': isStepLocked })}
>
<GripVerticalIcon className="h-4 w-4 opacity-40" />
</span>
<Badge variant="neutral" size="small">
<Trans>Step {step.order}</Trans>
</Badge>
{isGroup && (
<>
<span className="flex items-center gap-x-1.5 text-xs text-muted-foreground">
<Users2Icon className="h-3.5 w-3.5" />
<Trans>{step.members.length} signers · any order</Trans>
</span>
<Button
type="button"
variant="link"
size="sm"
data-testid="ungroup-step-button"
className="ml-auto h-auto p-0 text-xs"
disabled={isStepLocked || isSubmitting}
onClick={() => onUngroup(stepIndex)}
>
<Trans>Ungroup</Trans>
</Button>
</>
)}
</div>
{step.members.map((member, memberIndex) => {
const signerIndex = flatIndexByFormId.get(member.formId) ?? -1;
const canBeModified = canSignerBeModified(member);
return (
<Draggable
key={member.formId}
draggableId={`recipient-${member.formId}`}
index={memberIndex}
isDragDisabled={isSubmitting || !canBeModified}
>
{(memberProvided, memberSnapshot) => (
<div
ref={memberProvided.innerRef}
{...memberProvided.draggableProps}
className={cn({
'rounded-md bg-widget-foreground': memberSnapshot.isDragging,
})}
>
<RecipientRow
signerIndex={signerIndex}
signer={member}
isSequential={true}
isGrouped={isGroup}
isInputDisabled={memberSnapshot.isDragging || draggableSnapshot.isDragging}
canBeModified={canBeModified}
isRemoveDisabled={isRemoveDisabled}
dragHandleProps={memberProvided.dragHandleProps}
{...rowProps}
/>
</div>
)}
</Draggable>
);
})}
{droppableProvided.placeholder}
</div>
);
}}
</Droppable>
</div>
);
};
- Step 2: Type-check
Run: npx tsc --noEmit (workdir apps/remix)
Expected: no errors.
- Step 3: Commit
git add apps/remix/app/components/general/envelope-editor/recipient-step-card.tsx
git commit -m "feat: add envelope editor recipient step card"
Task 14: RecipientStepList component
Files:
- Create:
apps/remix/app/components/general/envelope-editor/recipient-step-list.tsx
Owns: recipient-suggestion search (moved from the form), all mutation handlers (role change, type-to-join order input, remove, ungroup), the nested DnD wiring, gap zones, the flat parallel-mode list, and CC cards.
- Step 1: Create the component
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import type { TEditorRecipientsFormSchema } from '@documenso/lib/client-only/hooks/use-editor-recipients';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import {
extractRecipientToNewStep,
groupRecipientsBySigningOrder,
mergeSteps,
moveRecipientToStep,
normalizeGroupedSigningOrders,
reorderStep,
ungroupStep,
} from '@documenso/lib/utils/recipient-groups';
import {
canEditorRecipientBeModified,
isAssistantLastSigner,
isCcRecipient,
} from '@documenso/lib/utils/recipients';
import { trpc } from '@documenso/trpc/react';
import type { RecipientAutoCompleteOption } from '@documenso/ui/components/recipient/recipient-autocomplete-input';
import { cn } from '@documenso/ui/lib/utils';
import { useToast } from '@documenso/ui/primitives/use-toast';
import type { BeforeCapture, DropResult } from '@hello-pangea/dnd';
import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd';
import { Trans, useLingui } from '@lingui/react/macro';
import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
import { useCallback, useMemo, useState } from 'react';
import { useFormContext } from 'react-hook-form';
import { RecipientRow } from './recipient-row';
import { type DraggingType, RecipientStepCard } from './recipient-step-card';
type TEditorSigner = TEditorRecipientsFormSchema['signers'][number];
const RecipientStepGap = ({ gapIndex, draggingType }: { gapIndex: number; draggingType: DraggingType }) => (
<Droppable droppableId={`gap-${gapIndex}`} type="RECIPIENT" isDropDisabled={draggingType !== 'RECIPIENT'}>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.droppableProps}
data-testid="recipient-step-gap"
className={cn(
'rounded-md transition-all',
draggingType === 'RECIPIENT' ? 'my-1 min-h-10 border border-dashed' : 'h-2',
{
'border-primary bg-primary/10': snapshot.isDraggingOver,
},
)}
>
<div className="hidden">{provided.placeholder}</div>
</div>
)}
</Droppable>
);
export type RecipientStepListProps = {
showAdvancedSettings: boolean;
};
export const RecipientStepList = ({ showAdvancedSettings }: RecipientStepListProps) => {
const { t } = useLingui();
const { toast } = useToast();
const { envelope, editorRecipients, isEmbedded } = useCurrentEnvelopeEditor();
const { form } = editorRecipients;
const [draggingType, setDraggingType] = useState<DraggingType>(null);
const [recipientSearchQuery, setRecipientSearchQuery] = useState('');
const debouncedRecipientSearchQuery = useDebouncedValue(recipientSearchQuery, 500);
const { data: recipientSuggestionsData, isLoading } = trpc.recipient.suggestions.find.useQuery(
{
query: debouncedRecipientSearchQuery,
},
{
enabled: debouncedRecipientSearchQuery.length > 1 && !isEmbedded,
retry: false,
},
);
const recipientSuggestions = recipientSuggestionsData?.results || [];
const watchedSigners = form.watch('signers');
const isSequential = form.watch('signingOrder') === DocumentSigningOrder.SEQUENTIAL;
const { isSubmitting } = form.formState;
const { steps, ccRecipients } = useMemo(() => groupRecipientsBySigningOrder(watchedSigners), [watchedSigners]);
const stepCount = steps.length;
const isRemoveDisabled = watchedSigners.length === 1;
const flatIndexByFormId = useMemo(
() => new Map(watchedSigners.map((signer, index) => [signer.formId, index])),
[watchedSigners],
);
const canSignerBeModified = useCallback(
(signer: TEditorSigner) => canEditorRecipientBeModified(envelope, signer.id),
[envelope],
);
const applySigners = useCallback(
(updatedSigners: TEditorSigner[], options: { warnWhenAssistantLast?: boolean } = {}) => {
const { warnWhenAssistantLast = true } = options;
form.setValue('signers', updatedSigners, {
shouldValidate: true,
shouldDirty: true,
});
if (warnWhenAssistantLast && isAssistantLastSigner(updatedSigners)) {
toast({
title: t`Warning: Assistant as last signer`,
description: t`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`,
});
}
void form.trigger('signers');
},
[form, t, toast],
);
const handleSigningOrderChange = useCallback(
(signerIndex: number, newOrderString: string) => {
const trimmedOrderString = newOrderString.trim();
if (!trimmedOrderString) {
return;
}
const newOrder = Number(trimmedOrderString);
if (!Number.isInteger(newOrder) || newOrder < 1) {
return;
}
const currentSigners = form.getValues('signers');
const signer = currentSigners[signerIndex];
if (!signer || isCcRecipient(signer)) {
return;
}
const { steps: currentSteps } = groupRecipientsBySigningOrder(currentSigners);
const currentStepIndex = currentSteps.findIndex((step) =>
step.members.some((member) => member.formId === signer.formId),
);
const targetStepIndex = newOrder - 1;
if (targetStepIndex === currentStepIndex) {
return;
}
// Typing an existing step number joins that step's group; an
// out-of-bounds number extracts the recipient to a standalone step at
// the end.
const updatedSigners =
targetStepIndex >= currentSteps.length
? extractRecipientToNewStep(currentSigners, signer.formId, currentSteps.length, canSignerBeModified)
: moveRecipientToStep(currentSigners, signer.formId, targetStepIndex, canSignerBeModified);
applySigners(updatedSigners, { warnWhenAssistantLast: signer.role === RecipientRole.ASSISTANT });
},
[form, canSignerBeModified, applySigners],
);
const handleRoleChange = useCallback(
(signerIndex: number, role: RecipientRole) => {
const currentSigners = form.getValues('signers');
const signingOrder = form.getValues('signingOrder');
if (role === RecipientRole.ASSISTANT && signingOrder === DocumentSigningOrder.PARALLEL) {
form.setValue('signingOrder', DocumentSigningOrder.SEQUENTIAL, {
shouldValidate: true,
shouldDirty: true,
});
toast({
title: t`Signing order is enabled.`,
description: t`You cannot add assistants when signing order is disabled.`,
variant: 'destructive',
});
return;
}
const updatedSigners = normalizeGroupedSigningOrders(
currentSigners.map((signer, index) => ({
...signer,
role: index === signerIndex ? role : signer.role,
})),
canSignerBeModified,
);
applySigners(updatedSigners, { warnWhenAssistantLast: role === RecipientRole.ASSISTANT });
},
[form, toast, t, canSignerBeModified, applySigners],
);
const handleRemove = useCallback(
(signerIndex: number) => {
const signer = form.getValues('signers')[signerIndex];
if (!signer) {
return;
}
if (!canSignerBeModified(signer)) {
toast({
title: t`Cannot remove signer`,
description: t`This signer has already signed the document.`,
variant: 'destructive',
});
return;
}
const updatedSigners = normalizeGroupedSigningOrders(
form.getValues('signers').filter((s) => s.formId !== signer.formId),
canSignerBeModified,
);
applySigners(updatedSigners, { warnWhenAssistantLast: false });
},
[form, toast, t, canSignerBeModified, applySigners],
);
const handleUngroup = useCallback(
(stepIndex: number) => {
applySigners(ungroupStep(form.getValues('signers'), stepIndex, canSignerBeModified));
},
[form, canSignerBeModified, applySigners],
);
const handleAutoCompleteSelect = useCallback(
(signerIndex: number, suggestion: RecipientAutoCompleteOption) => {
form.setValue(`signers.${signerIndex}.email`, suggestion.email, {
shouldValidate: true,
shouldDirty: true,
});
form.setValue(`signers.${signerIndex}.name`, suggestion.name || '', {
shouldValidate: true,
shouldDirty: true,
});
},
[form],
);
const onBeforeCapture = useCallback((before: BeforeCapture) => {
setDraggingType(before.draggableId.startsWith('step-') ? 'STEP' : 'RECIPIENT');
}, []);
const onDragEnd = useCallback(
(result: DropResult) => {
setDraggingType(null);
const currentSigners = form.getValues('signers');
if (result.type === 'STEP') {
if (result.combine) {
const targetStepIndex = Number(result.combine.draggableId.slice('step-'.length));
applySigners(mergeSteps(currentSigners, result.source.index, targetStepIndex, canSignerBeModified));
return;
}
if (result.destination) {
applySigners(reorderStep(currentSigners, result.source.index, result.destination.index, canSignerBeModified));
}
return;
}
if (result.type === 'RECIPIENT' && result.destination) {
const formId = result.draggableId.slice('recipient-'.length);
const { droppableId } = result.destination;
if (droppableId.startsWith('gap-')) {
const gapIndex = Number(droppableId.slice('gap-'.length));
applySigners(extractRecipientToNewStep(currentSigners, formId, gapIndex, canSignerBeModified));
return;
}
if (droppableId.startsWith('step-members-')) {
const targetStepIndex = Number(droppableId.slice('step-members-'.length));
applySigners(moveRecipientToStep(currentSigners, formId, targetStepIndex, canSignerBeModified));
}
}
},
[form, canSignerBeModified, applySigners],
);
const sharedRowProps = {
stepCount,
showAdvancedSettings,
recipientSuggestions,
isLoadingSuggestions: isLoading,
onSigningOrderChange: handleSigningOrderChange,
onRoleChange: handleRoleChange,
onRemove: handleRemove,
onAutoCompleteSelect: handleAutoCompleteSelect,
onSearchQueryChange: setRecipientSearchQuery,
};
return (
<div>
{!showAdvancedSettings && (
<div className={cn('mb-1 flex flex-row gap-x-2 text-sm', { 'pl-[4.75rem]': isSequential })}>
<span className="w-full">
<Trans>Email</Trans>
</span>
<span className="w-full">
<Trans>Name</Trans>
</span>
<span className="w-[7.5rem] flex-shrink-0" />
</div>
)}
{!isSequential ? (
<div className="flex w-full flex-col">
{watchedSigners.map((signer, index) => (
<RecipientRow
key={signer.formId}
signerIndex={index}
signer={signer}
isSequential={false}
isGrouped={false}
isInputDisabled={false}
canBeModified={canSignerBeModified(signer)}
isRemoveDisabled={isRemoveDisabled}
dragHandleProps={null}
{...sharedRowProps}
/>
))}
</div>
) : (
<>
<DragDropContext onBeforeCapture={onBeforeCapture} onDragEnd={onDragEnd}>
<Droppable droppableId="recipient-steps" type="STEP" isCombineEnabled>
{(provided) => (
<div {...provided.droppableProps} ref={provided.innerRef} className="flex w-full flex-col">
{steps.map((step, stepIndex) => {
const isStepLocked = step.members.some((member) => !canSignerBeModified(member));
return (
<div key={`step-fragment-${step.members[0].formId}`}>
<RecipientStepGap gapIndex={stepIndex} draggingType={draggingType} />
<Draggable
draggableId={`step-${stepIndex}`}
index={stepIndex}
isDragDisabled={isSubmitting || isStepLocked}
>
{(draggableProvided, draggableSnapshot) => (
<RecipientStepCard
stepIndex={stepIndex}
step={step}
draggableProvided={draggableProvided}
draggableSnapshot={draggableSnapshot}
draggingType={draggingType}
isStepLocked={isStepLocked}
isRemoveDisabled={isRemoveDisabled}
flatIndexByFormId={flatIndexByFormId}
canSignerBeModified={canSignerBeModified}
isSubmitting={isSubmitting}
onUngroup={handleUngroup}
rowProps={sharedRowProps}
/>
)}
</Draggable>
</div>
);
})}
<RecipientStepGap gapIndex={steps.length} draggingType={draggingType} />
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
{ccRecipients.map((signer) => (
<div key={signer.formId} className="my-1 rounded-lg border px-3 py-1">
<RecipientRow
signerIndex={flatIndexByFormId.get(signer.formId) ?? -1}
signer={signer}
isSequential={true}
isGrouped={false}
isInputDisabled={false}
canBeModified={canSignerBeModified(signer)}
isRemoveDisabled={isRemoveDisabled}
dragHandleProps={null}
{...sharedRowProps}
/>
</div>
))}
</>
)}
</div>
);
};
- Step 2: Type-check
Run: npx tsc --noEmit (workdir apps/remix)
Expected: no errors.
- Step 3: Commit
git add apps/remix/app/components/general/envelope-editor/recipient-step-list.tsx
git commit -m "feat: add envelope editor recipient step list with group drag and drop"
Task 15: Rewire envelope-editor-recipient-form.tsx
Files:
- Modify:
apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx
The form keeps: card header (AI detect / Add Myself / Add Signer), the signing-order + dictate checkboxes, the autosave watch-effect (lines 524-588 — unchanged logic), the recipient-limit alert, SigningOrderConfirmation, and the AI dialogs. Everything list-related moves to RecipientStepList.
- Step 1: Update imports
Remove these imports (now unused here): useDebouncedValue, normalizeRecipientSigningOrders and canRecipientBeModified as utilCanRecipientBeModified (from utils/recipients), trpc, RecipientActionAuthSelect, RecipientAutoCompleteInput/RecipientAutoCompleteOption, RecipientRoleSelect, Input, FormMessage (keep Form, FormControl, FormField, FormItem, FormLabel — all still used by the signing-order/dictate checkboxes), DragDropContext/Draggable/Droppable/DropResult/SensorAPI, motion, GripVerticalIcon/TrashIcon, useFieldArray. Keep useRef (still used by isFirstRender).
Keep: isAssistantLastSigner, isCcRecipient (used by emptySignerIndex/activeRecipientCount replacement below), plural, SendStatus, etc.
Add:
import { groupRecipientsBySigningOrder, normalizeGroupedSigningOrders } from '@documenso/lib/utils/recipient-groups';
import { canEditorRecipientBeModified } from '@documenso/lib/utils/recipients';
import { RecipientStepList } from './recipient-step-list';
- Step 2: Replace helpers
Delete the $sensorApi ref (line 112) and the useFieldArray block (lines 169-173).
Replace the canRecipientBeModified local function (lines 194-210) with:
const canRecipientBeModified = (recipientId?: number) => canEditorRecipientBeModified(envelope, recipientId);
Replace normalizeSigningOrders (lines 163-165) with:
const normalizeSigningOrders = (signers: typeof watchedSigners) => {
return normalizeGroupedSigningOrders(signers, (signer) => canRecipientBeModified(signer.id));
};
Replace activeRecipientCount (line 167) with a step count:
const stepCount = useMemo(() => groupRecipientsBySigningOrder(watchedSigners).steps.length, [watchedSigners]);
In onAddSigner (line 236) and onAddSelfSigner (line 351), replace signingOrder: activeRecipientCount + 1 with signingOrder: stepCount + 1.
Delete these now-moved functions entirely: onDragEnd (lines 371-405), handleRoleChange (lines 407-446), handleSigningOrderChange (lines 448-494), onRemoveSigner (lines 305-329), handleRecipientAutoCompleteSelect (lines 360-369), and the recipient-suggestions state/query (lines 64, 110, 116-126).
Replace all remaining references to the removed signers field-array variable with watchedSigners:
-
isOverRecipientLimit(line 591):watchedSigners.length > recipientCountLimit -
Add Signer disabled (line 647):
watchedSigners.length >= remaining.recipients -
Step 3: Replace the list markup
Inside <Form {...form}>, replace everything from <DragDropContext (line 797) through its closing </DragDropContext> (line 1077) with:
<RecipientStepList showAdvancedSettings={showAdvancedSettings} />
Keep the <FormErrorMessage ... signers__root ... /> block after it unchanged.
- Step 4: Type-check and lint
Run: npx tsc --noEmit (workdir apps/remix), then npm run lint
Expected: no errors; lint may flag leftover unused imports — remove any it reports.
- Step 5: Manual smoke test
Run: npm run dev — open a document editor, verify: sequential off → flat rows; sequential on → step cards with badges; typing a duplicate order groups two recipients (green card, 2 signers · any order, Ungroup); dragging a solo card onto another shows the green ring + "Release to sign together" badge and merges; dragging a member row out to a dashed gap extracts it; Ungroup dissolves; autosave persists (reload keeps the group).
- Step 6: Commit
git add apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx
git commit -m "feat: render envelope editor recipients as signing step cards"
Task 16: E2E — editor grouping
Files:
- Create:
packages/app-tests/e2e/envelope-editor-v2/envelope-recipient-groups.spec.ts
Follows the envelope-editor-v2 fixture pattern. Grouping is exercised through the type-to-join path (drag correctness is covered by unit tests; DnD in Playwright is flaky).
- Step 1: Write the spec
import { expect, test } from '@playwright/test';
import { prisma } from '@documenso/prisma';
import {
type TEnvelopeEditorSurface,
clickAddSignerButton,
getSigningOrderInputs,
openDocumentEnvelopeEditor,
openTemplateEnvelopeEditor,
setRecipientEmail,
setRecipientName,
setSigningOrderValue,
toggleSigningOrder,
} from '../fixtures/envelope-editor';
const expectRecipientOrders = async (surface: TEnvelopeEditorSurface, expected: Array<[string, number]>) => {
await expect
.poll(
async () => {
const recipients = await prisma.recipient.findMany({
where: { envelopeId: surface.envelopeId },
});
return recipients.map((r) => [r.email, r.signingOrder] as const).sort((a, b) => a[0].localeCompare(b[0]));
},
{ timeout: 15_000 },
)
.toEqual([...expected].sort((a, b) => a[0].localeCompare(b[0])));
};
const runGroupingFlow = async (surface: TEnvelopeEditorSurface) => {
const { root } = surface;
await setRecipientEmail(root, 0, 'alice@example.com');
await setRecipientName(root, 0, 'Alice');
await clickAddSignerButton(root);
await setRecipientEmail(root, 1, 'bob@example.com');
await clickAddSignerButton(root);
await setRecipientEmail(root, 2, 'carol@example.com');
await toggleSigningOrder(root, true);
// Three standalone steps.
await expect(root.getByText('Step 1', { exact: true })).toBeVisible();
await expect(root.getByText('Step 3', { exact: true })).toBeVisible();
// Type-to-join: carol (step 3) joins bob (step 2).
await setSigningOrderValue(root, 2, '2');
await expect(root.getByText('2 signers · any order')).toBeVisible();
await expect(root.getByTestId('ungroup-step-button')).toBeVisible();
await expect(root.getByText('Step 3', { exact: true })).not.toBeVisible();
const orderInputs = getSigningOrderInputs(root);
await expect(orderInputs.nth(1)).toHaveValue('2');
await expect(orderInputs.nth(2)).toHaveValue('2');
await expectRecipientOrders(surface, [
['alice@example.com', 1],
['bob@example.com', 2],
['carol@example.com', 2],
]);
// Groups survive a reload (grouped normalization on load).
await root.reload();
await expect(root.getByText('2 signers · any order')).toBeVisible();
// Ungroup dissolves back into sequential steps.
await root.getByTestId('ungroup-step-button').click();
await expect(root.getByText('2 signers · any order')).not.toBeVisible();
await expect(root.getByText('Step 3', { exact: true })).toBeVisible();
await expectRecipientOrders(surface, [
['alice@example.com', 1],
['bob@example.com', 2],
['carol@example.com', 3],
]);
// Out-of-bounds extraction: bob (step 2) types 4 (> 3 steps) and becomes the
// last standalone step.
await setSigningOrderValue(root, 1, '4');
await expectRecipientOrders(surface, [
['alice@example.com', 1],
['bob@example.com', 3],
['carol@example.com', 2],
]);
};
test.describe('document editor', () => {
test('documents: group recipients via signing order input and ungroup', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
await runGroupingFlow(surface);
});
});
test.describe('template editor', () => {
test('templates: group recipients via signing order input and ungroup', async ({ page }) => {
const surface = await openTemplateEnvelopeEditor(page);
await runGroupingFlow(surface);
});
});
- Step 2: Run the spec
Run: npm run test:dev -w @documenso/app-tests -- e2e/envelope-editor-v2/envelope-recipient-groups.spec.ts
Expected: 2 tests pass. If the Step N badge text or the group header copy differs from the implementation, fix the spec assertions to match the rendered strings (they are defined in Task 13).
- Step 3: Commit
git add packages/app-tests/e2e/envelope-editor-v2/envelope-recipient-groups.spec.ts
git commit -m "test: add envelope editor recipient grouping e2e"
Task 17: E2E — group signing flow
Files:
- Create:
packages/app-tests/e2e/recipient/signing-groups.spec.ts
Seeds a pending document with orders [1, 2, 2, 3] and walks the full flow: waiting-page gating, any-order signing within the group, gate release, completion.
- Step 1: Write the spec
import { expect, test } from '@playwright/test';
import type { Page } from '@playwright/test';
import { DocumentSigningOrder, DocumentStatus, FieldType } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { signSignaturePad } from '../fixtures/signature';
type SeededRecipient = Awaited<ReturnType<typeof seedPendingDocumentWithFullFields>>['recipients'][number];
const completeSigning = async (page: Page, recipient: SeededRecipient) => {
const signUrl = `/sign/${recipient.token}`;
await page.goto(signUrl);
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
await signSignaturePad(page);
for (const field of recipient.fields) {
await page.locator(`#field-${field.id}`).getByRole('button').click();
if (field.type === FieldType.TEXT) {
await page.locator('#custom-text').fill('TEXT');
await page.getByRole('button', { name: 'Save' }).click();
}
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
}
await page.getByRole('button', { name: 'Complete' }).click();
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(`${signUrl}/complete`);
};
const expectWaiting = async (page: Page, token: string) => {
await page.goto(`/sign/${token}`);
await page.waitForURL(`/sign/${token}/waiting`);
};
test('[SIGNING_GROUPS]: group members sign in any order and gate the next step', async ({ page }) => {
const { user, team } = await seedUser();
const { user: signer1 } = await seedUser();
const { user: signer2a } = await seedUser();
const { user: signer2b } = await seedUser();
const { user: signer3 } = await seedUser();
const { recipients, document } = await seedPendingDocumentWithFullFields({
owner: user,
teamId: team.id,
recipients: [signer1, signer2a, signer2b, signer3],
recipientsCreateOptions: [
{ signingOrder: 1 },
{ signingOrder: 2 },
{ signingOrder: 2 },
{ signingOrder: 3 },
],
updateDocumentOptions: {
documentMeta: {
upsert: {
create: { signingOrder: DocumentSigningOrder.SEQUENTIAL },
update: { signingOrder: DocumentSigningOrder.SEQUENTIAL },
},
},
},
});
const [recipient1, recipient2a, recipient2b, recipient3] = recipients;
// While step 1 is pending, both group members and step 3 are blocked.
await expectWaiting(page, recipient2a.token);
await expectWaiting(page, recipient2b.token);
await expectWaiting(page, recipient3.token);
await completeSigning(page, recipient1);
// Step 3 is still blocked; the group is now active.
await expectWaiting(page, recipient3.token);
// Sign with the SECOND group member first to prove any-order signing.
await completeSigning(page, recipient2b);
// One group member remains — step 3 stays blocked.
await expectWaiting(page, recipient3.token);
await completeSigning(page, recipient2a);
// The whole group is done — step 3 unlocks and completes the document.
await completeSigning(page, recipient3);
await expect
.poll(async () => {
const envelope = await prisma.envelope.findUniqueOrThrow({
where: { id: document.id },
});
return envelope.status;
})
.toBe(DocumentStatus.COMPLETED);
});
- Step 2: Run the spec
Run: npm run test:dev -w @documenso/app-tests -- e2e/recipient/signing-groups.spec.ts
Expected: 1 test passes. If the post-Complete confirmation dialog has no Sign button in this configuration (no dictation), drop the second click — mirror whatever e2e/document-auth/next-recipient-dictation.spec.ts's disabled-dictation test does.
- Step 3: Commit
git add packages/app-tests/e2e/recipient/signing-groups.spec.ts
git commit -m "test: add signing group flow e2e"
Task 18: Final verification
- Step 1: Unit tests
Run: npm run test -w @documenso/lib
Expected: all pass.
- Step 2: Type checks
Run: npx tsc --noEmit in packages/lib, packages/trpc, and apps/remix (three separate runs, using each as workdir).
Expected: no errors.
- Step 3: Lint
Run: npm run lint
Expected: clean (run npm run lint:fix for autofixable issues).
- Step 4: Full E2E for touched areas
Run: npm run test:dev -w @documenso/app-tests -- e2e/envelope-editor-v2/ e2e/recipient/signing-groups.spec.ts e2e/document-auth/next-recipient-dictation.spec.ts
Expected: all pass — the dictation spec proves single-file documents still dictate correctly after the group changes.
- Step 5: Commit any remaining fixes
git status
git add -A && git commit -m "fix: address verification findings for signing groups"
Only commit if there are changes; use a message describing the actual fix.
Coverage map (spec → tasks)
| Spec section | Tasks |
|---|---|
| §1 Data model & ordering | 1, 3 |
| §2 Shared pure utilities | 1, 2, 3, 4 |
| §3 Editor UI structure & visuals | 12, 13, 14, 15 |
| §4 Editor interactions (drag, type-to-join, ungroup, locked) | 2, 14, 15 |
| §5.1 Turn checks | 3, 5 |
| §5.2 Initial send | 6 |
| §5.3 Completion advance | 7, 8 (direct template) |
| §5.4 Dictation gating | 3, 7, 8, 10 |
| §5.5 Assistants | 4, 9 |
| §5.6 CSC blocking | 11 |
| §6 Validation & compatibility (grouped load, V1 untouched) | 11 |
| §7 Testing | 1-4 (unit), 16-17 (E2E), 18 |