--- 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 |