diff --git a/.agents/plans/brave-coral-lake-recipient-signing-groups-implementation.md b/.agents/plans/brave-coral-lake-recipient-signing-groups-implementation.md new file mode 100644 index 000000000..528d1f644 --- /dev/null +++ b/.agents/plans/brave-coral-lake-recipient-signing-groups-implementation.md @@ -0,0 +1,2992 @@ +--- +date: 2026-08-04 +title: 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, ``/`` 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 + `` | +| `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`: + +```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`: + +```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 & { + signingOrder?: number | null; +}; + +export type RecipientStep = { + /** + * 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 = (recipients: T[]) => { + const ccRecipients = recipients.filter((recipient) => isCcRecipient(recipient)); + const nonCcRecipients = recipients.filter((recipient) => !isCcRecipient(recipient)); + + const membersByOrder = new Map(); + + for (const recipient of nonCcRecipients) { + const order = effectiveOrder(recipient); + const members = membersByOrder.get(order) ?? []; + + members.push(recipient); + membersByOrder.set(order, members); + } + + const steps: RecipientStep[] = [...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 = ( + recipients: T[], + canUpdateRecipient: (recipient: T) => boolean = () => true, +): Array => { + const { steps, ccRecipients } = groupRecipientsBySigningOrder(recipients); + + const lockedOrderByStepIndex = new Map(); + + 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[] = []; + + 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** + +```bash +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`): + +```ts +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`: + +```ts +type EditorRecipient = GroupableRecipient & { formId: string }; + +/** + * Merges all members of the source step into the target step. + */ +export const mergeSteps = ( + recipients: T[], + sourceStepIndex: number, + targetStepIndex: number, + canUpdateRecipient?: (recipient: T) => boolean, +): Array => { + 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 = ( + recipients: T[], + formId: string, + targetStepIndex: number, + canUpdateRecipient?: (recipient: T) => boolean, +): Array => { + 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 = ( + recipients: T[], + formId: string, + insertStepIndex: number, + canUpdateRecipient?: (recipient: T) => boolean, +): Array => { + 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 = ( + recipients: T[], + fromStepIndex: number, + toStepIndex: number, + canUpdateRecipient: (recipient: T) => boolean = () => true, +): Array => { + 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) => 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 = ( + recipients: T[], + stepIndex: number, + canUpdateRecipient?: (recipient: T) => boolean, +): Array => { + 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** + +```bash +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`): + +```ts +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): + +```ts +type SignableRecipient = Pick & { + 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 = ( + 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 = ( + 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 = >({ + 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** + +```bash +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`: + +```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: + +```ts +/** + * 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 & { 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** + +```bash +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: + +```ts +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: + +```ts + const isRecipientsTurn = + envelope.documentMeta.signingOrder !== DocumentSigningOrder.SEQUENTIAL || + isRecipientTurnBySigningOrder(envelope.recipients, recipient); +``` + +Add the import: + +```ts +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** + +```bash +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: + +```ts + 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: + +```ts + 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: + +```ts +import { filterRecipientsInFirstSigningGroup } from '../../utils/recipient-groups'; +``` + +- [ ] **Step 2: Type-check** + +Run: `npx tsc --noEmit` (workdir `packages/lib`) +Expected: no new errors. + +- [ ] **Step 3: Commit** + +```bash +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 `sendStatus` to the pending query select** + +In the `pendingRecipients` query (lines 368-388), extend the `select`: + +```ts + 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: + +```ts + 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: + +```ts +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** + +```bash +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: + +```ts + const nextRecipient = pendingRecipients[0]; +``` + +with: + +```ts + 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: + +```ts +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: + +```ts +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** + +```bash +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): + +```ts + 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): + +```ts + ...(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** + +```bash +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: + +```ts + 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: + +```ts +import { getDictatableNextRecipient } from '@documenso/lib/utils/recipient-groups'; +``` + +- [ ] **Step 2: `document-signing-page-view-v1.tsx`** + +Replace the `nextRecipient` `useMemo` (lines 145-171) with: + +```ts + 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: + +```ts + 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** + +```bash +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: + +```ts + ? normalizeRecipientSigningOrders(sortRecipientsForSigningOrder(formRecipients)) +``` + +with: + +```ts + ? normalizeGroupedSigningOrders(sortRecipientsForSigningOrder(formRecipients)) +``` + +Update the import at line 12: replace `normalizeRecipientSigningOrders` with `normalizeGroupedSigningOrders` from `../../utils/recipient-groups` (keep `isCcRecipient` and `sortRecipientsForSigningOrder` from `../../utils/recipients`): + +```ts +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: + +```ts + const seenSigningOrders = new Set(); + + 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 `canEditorRecipientBeModified` to `recipients.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';`): + +```ts +/** + * Editor-level wrapper around `canRecipientBeModified`. + * + * Template recipients and unsaved (id-less) recipients can always be modified. + */ +export const canEditorRecipientBeModified = ( + envelope: Pick, + 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** + +```bash +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** + +```tsx +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(); + + 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 ( +
+
+ {isSequential && !isCcRecipient(signer) && ( + ( + + + + + + + { + field.onChange(e); + onSigningOrderChange(signerIndex, e.target.value); + }} + onBlur={(e) => { + field.onBlur(); + onSigningOrderChange(signerIndex, e.target.value); + }} + disabled={isFieldDisabled} + /> + + + + )} + /> + )} + + ( + + + onAutoCompleteSelect(signerIndex, suggestion)} + onSearchQueryChange={(query) => { + field.onChange(query); + onSearchQueryChange(query); + }} + loading={isLoadingSuggestions} + data-testid="signer-email-input" + maxLength={254} + /> + + + + + )} + /> + + ( + + + onAutoCompleteSelect(signerIndex, suggestion)} + onSearchQueryChange={(query) => { + field.onChange(query); + onSearchQueryChange(query); + }} + loading={isLoadingSuggestions} + maxLength={255} + /> + + + + + )} + /> + + ( + + + { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + onRoleChange(signerIndex, value as RecipientRole); + }} + disabled={isFieldDisabled} + /> + + + + + )} + /> + + +
+ + {showAdvancedSettings && organisation.organisationClaim.flags.cfr21 && ( + ( + + + + + + + + )} + /> + )} +
+ ); +}; +``` + +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** + +```bash +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** + +```tsx +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; + draggableProvided: DraggableProvided; + draggableSnapshot: DraggableStateSnapshot; + draggingType: DraggingType; + isStepLocked: boolean; + isRemoveDisabled: boolean; + flatIndexByFormId: Map; + 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 ( +
+ + {(droppableProvided, droppableSnapshot) => { + const isJoinTarget = draggingType === 'RECIPIENT' && droppableSnapshot.isDraggingOver; + const isHighlighted = isCombineTarget || isJoinTarget; + + return ( +
+ {isHighlighted && ( + + + Release to sign together + + )} + +
+ + + + + + Step {step.order} + + + {isGroup && ( + <> + + + {step.members.length} signers · any order + + + + + )} +
+ + {step.members.map((member, memberIndex) => { + const signerIndex = flatIndexByFormId.get(member.formId) ?? -1; + const canBeModified = canSignerBeModified(member); + + return ( + + {(memberProvided, memberSnapshot) => ( +
+ +
+ )} +
+ ); + })} + + {droppableProvided.placeholder} +
+ ); + }} +
+
+ ); +}; +``` + +- [ ] **Step 2: Type-check** + +Run: `npx tsc --noEmit` (workdir `apps/remix`) +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +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** + +```tsx +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 }) => ( + + {(provided, snapshot) => ( +
+
{provided.placeholder}
+
+ )} +
+); + +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(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 ( +
+ {!showAdvancedSettings && ( +
+ + Email + + + Name + + +
+ )} + + {!isSequential ? ( +
+ {watchedSigners.map((signer, index) => ( + + ))} +
+ ) : ( + <> + + + {(provided) => ( +
+ {steps.map((step, stepIndex) => { + const isStepLocked = step.members.some((member) => !canSignerBeModified(member)); + + return ( +
+ + + + {(draggableProvided, draggableSnapshot) => ( + + )} + +
+ ); + })} + + + + {provided.placeholder} +
+ )} +
+
+ + {ccRecipients.map((signer) => ( +
+ +
+ ))} + + )} +
+ ); +}; +``` + +- [ ] **Step 2: Type-check** + +Run: `npx tsc --noEmit` (workdir `apps/remix`) +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +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: + +```ts +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: + +```ts + const canRecipientBeModified = (recipientId?: number) => canEditorRecipientBeModified(envelope, recipientId); +``` + +Replace `normalizeSigningOrders` (lines 163-165) with: + +```ts + const normalizeSigningOrders = (signers: typeof watchedSigners) => { + return normalizeGroupedSigningOrders(signers, (signer) => canRecipientBeModified(signer.id)); + }; +``` + +Replace `activeRecipientCount` (line 167) with a step count: + +```ts + 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 `
`, replace everything from `` (line 1077) with: + +```tsx + +``` + +Keep the `` 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** + +```bash +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** + +```ts +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** + +```bash +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** + +```ts +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>['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** + +```bash +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** + +```bash +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 | + + + +