Files
documenso/.agents/plans/brave-coral-lake-recipient-signing-groups-implementation.md
T

102 KiB
Raw Blame History

date, title
date title
2026-08-04 Recipient Signing Groups Implementation

Recipient Signing Groups Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Allow recipients to share a signingOrder value ("signing groups") in the V2 envelope editor so grouped recipients sign in any order among themselves, gating the next step until all members complete.

Architecture: Groups are derived from duplicate signingOrder values — no schema change (Recipient.signingOrder is already a nullable, non-unique Int). Pure group utilities live in packages/lib/utils/recipient-groups.ts and are shared by the editor UI and the server signing flow. The editor renders steps as cards with nested Kanban drag-and-drop (@hello-pangea/dnd: outer STEP droppable with combine, inner RECIPIENT droppables, gap drop-zones). Server "single next recipient" decision points switch to group semantics (value-based turn check, min-order group activation).

Tech Stack: TypeScript, React (Remix), react-hook-form, Zod, @hello-pangea/dnd, Prisma, tRPC, vitest, Playwright.

Spec: .agents/plans/quiet-jade-river-recipient-signing-groups.md — read it first.

Repo rules that apply to every task: no classes, type over interface, named exports, no 1-line if statements, <Trans>/t` `/plural macros for all user-facing strings, const Component = () => {} for components.


File Map

File Action Responsibility
packages/lib/utils/recipient-groups.ts Create Pure group derivation, normalization, editor operations, turn predicate, dictation helper
packages/lib/utils/recipient-groups.test.ts Create Unit tests for all of the above
packages/lib/utils/recipients.ts Modify Group-aware isAssistantLastSigner, new canEditorRecipientBeModified
packages/lib/utils/recipients.test.ts Modify Tests for group-aware assistant check
packages/lib/server-only/recipient/get-is-recipient-turn.ts Modify Value-based turn check
packages/lib/server-only/envelope/get-envelope-for-recipient-signing.ts Modify Delete inline turn loop, use shared predicate
packages/lib/server-only/document/send-document.ts Modify Notify whole first pending group
packages/lib/server-only/document/complete-document-with-token.ts Modify Group advance + dictation gating
packages/lib/server-only/template/create-document-from-direct-template.ts Modify Dictation only for single-member next step
packages/lib/server-only/recipient/get-next-pending-recipient.ts Modify Use getDictatableNextRecipient
packages/lib/server-only/recipient/get-recipients-for-assistant.ts Modify Strictly-greater assistant scope (self preserved)
packages/trpc/server/envelope-router/sign-envelope-field.ts Modify Strictly-greater assistant scope (self preserved)
apps/remix/app/components/general/document-signing/envelope-signing-provider.tsx Modify Use getDictatableNextRecipient
apps/remix/app/components/general/document-signing/document-signing-page-view-v1.tsx Modify Use getDictatableNextRecipient
apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx Modify Use getDictatableNextRecipient
packages/lib/client-only/hooks/use-editor-recipients.ts Modify Grouped normalize on load, CSC no-duplicates validation
apps/remix/app/components/general/envelope-editor/recipient-row.tsx Create Single recipient row (moved from form)
apps/remix/app/components/general/envelope-editor/recipient-step-card.tsx Create Step card chrome, inner droppable, group header
apps/remix/app/components/general/envelope-editor/recipient-step-list.tsx Create DnD orchestration, gap zones, all mutation handlers
apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx Modify Slim down to header/checkboxes/sync + <RecipientStepList />
packages/app-tests/e2e/envelope-editor-v2/envelope-recipient-groups.spec.ts Create Editor E2E (type-to-join, ungroup, persistence)
packages/app-tests/e2e/recipient/signing-groups.spec.ts Create Signing-flow E2E (any-order group, gating)

Execution note: create an isolated worktree first (superpowers:using-git-worktrees). Tasks 14 are pure lib (TDD with vitest). Tasks 511 are backend/lib wiring (verified by tsc + existing tests + E2E later). Tasks 1215 are UI. Tasks 1617 are E2E. Task 18 is final verification.


Task 1: Group derivation + grouped normalization

Files:

  • Create: packages/lib/utils/recipient-groups.ts

  • Create: packages/lib/utils/recipient-groups.test.ts

  • Step 1: Write the failing tests

Create packages/lib/utils/recipient-groups.test.ts:

import { RecipientRole } from '@prisma/client';
import { describe, expect, it } from 'vitest';

import { groupRecipientsBySigningOrder, normalizeGroupedSigningOrders } from './recipient-groups';

describe('groupRecipientsBySigningOrder', () => {
  it('groups non-CC recipients sharing a signing order into steps', () => {
    const recipients = [
      { formId: 'a', role: RecipientRole.SIGNER, signingOrder: 1 },
      { formId: 'b', role: RecipientRole.SIGNER, signingOrder: 2 },
      { formId: 'c', role: RecipientRole.APPROVER, signingOrder: 2 },
      { formId: 'd', role: RecipientRole.SIGNER, signingOrder: 3 },
    ];

    const { steps, ccRecipients } = groupRecipientsBySigningOrder(recipients);

    expect(ccRecipients).toEqual([]);
    expect(steps.map((step) => step.order)).toEqual([1, 2, 3]);
    expect(steps.map((step) => step.members.map((m) => m.formId))).toEqual([['a'], ['b', 'c'], ['d']]);
  });

  it('excludes CC recipients from steps', () => {
    const recipients = [
      { formId: 'a', role: RecipientRole.SIGNER, signingOrder: 1 },
      { formId: 'b', role: RecipientRole.CC, signingOrder: undefined },
    ];

    const { steps, ccRecipients } = groupRecipientsBySigningOrder(recipients);

    expect(steps).toHaveLength(1);
    expect(ccRecipients.map((r) => r.formId)).toEqual(['b']);
  });

  it('sorts steps by order regardless of input order and keeps member input order', () => {
    const recipients = [
      { formId: 'c', role: RecipientRole.SIGNER, signingOrder: 2 },
      { formId: 'a', role: RecipientRole.SIGNER, signingOrder: 1 },
      { formId: 'b', role: RecipientRole.SIGNER, signingOrder: 2 },
    ];

    const { steps } = groupRecipientsBySigningOrder(recipients);

    expect(steps.map((step) => step.members.map((m) => m.formId))).toEqual([['a'], ['c', 'b']]);
  });

  it('collects recipients without a signing order into a single tail step', () => {
    const recipients = [
      { formId: 'a', role: RecipientRole.SIGNER, signingOrder: 1 },
      { formId: 'b', role: RecipientRole.SIGNER, signingOrder: null },
      { formId: 'c', role: RecipientRole.SIGNER, signingOrder: undefined },
    ];

    const { steps } = groupRecipientsBySigningOrder(recipients);

    expect(steps).toHaveLength(2);
    expect(steps[1].members.map((m) => m.formId)).toEqual(['b', 'c']);
  });
});

describe('normalizeGroupedSigningOrders', () => {
  it('preserves groups while compacting gaps to dense step numbers', () => {
    const recipients = [
      { formId: 'a', role: RecipientRole.SIGNER, signingOrder: 2 },
      { formId: 'b', role: RecipientRole.SIGNER, signingOrder: 5 },
      { formId: 'c', role: RecipientRole.SIGNER, signingOrder: 5 },
      { formId: 'd', role: RecipientRole.SIGNER, signingOrder: 9 },
    ];

    expect(normalizeGroupedSigningOrders(recipients).map((r) => r.signingOrder)).toEqual([1, 2, 2, 3]);
  });

  it('moves CC recipients to the tail with an undefined signing order', () => {
    const recipients = [
      { formId: 'cc', role: RecipientRole.CC, signingOrder: 1 },
      { formId: 'a', role: RecipientRole.SIGNER, signingOrder: 3 },
      { formId: 'b', role: RecipientRole.SIGNER, signingOrder: 3 },
    ];

    const normalized = normalizeGroupedSigningOrders(recipients);

    expect(normalized.map((r) => r.formId)).toEqual(['a', 'b', 'cc']);
    expect(normalized.map((r) => r.signingOrder)).toEqual([1, 1, undefined]);
  });

  it('anchors steps containing locked recipients to their persisted order', () => {
    const recipients = [
      { formId: 'locked', role: RecipientRole.SIGNER, signingOrder: 1 },
      { formId: 'a', role: RecipientRole.SIGNER, signingOrder: 4 },
      { formId: 'b', role: RecipientRole.SIGNER, signingOrder: 4 },
    ];

    const normalized = normalizeGroupedSigningOrders(recipients, (r) => r.formId !== 'locked');

    expect(normalized.map((r) => [r.formId, r.signingOrder])).toEqual([
      ['locked', 1],
      ['a', 2],
      ['b', 2],
    ]);
  });

  it('never renumbers an editable step onto a locked step number', () => {
    const recipients = [
      { formId: 'a', role: RecipientRole.SIGNER, signingOrder: 1 },
      { formId: 'locked', role: RecipientRole.SIGNER, signingOrder: 2 },
      { formId: 'b', role: RecipientRole.SIGNER, signingOrder: 5 },
    ];

    const normalized = normalizeGroupedSigningOrders(recipients, (r) => r.formId !== 'locked');

    // 'b' must skip the reserved locked number 2 and take 3, not collide into 2.
    expect(normalized.map((r) => [r.formId, r.signingOrder])).toEqual([
      ['a', 1],
      ['locked', 2],
      ['b', 3],
    ]);
  });

  it('keeps a group intact when it contains the locked recipient', () => {
    const recipients = [
      { formId: 'locked', role: RecipientRole.SIGNER, signingOrder: 2 },
      { formId: 'peer', role: RecipientRole.SIGNER, signingOrder: 2 },
      { formId: 'a', role: RecipientRole.SIGNER, signingOrder: 7 },
    ];

    const normalized = normalizeGroupedSigningOrders(recipients, (r) => r.formId !== 'locked');

    expect(normalized.map((r) => [r.formId, r.signingOrder])).toEqual([
      ['locked', 2],
      ['peer', 2],
      ['a', 3],
    ]);
  });
});
  • Step 2: Run tests to verify they fail

Run: npm run test -w @documenso/lib -- utils/recipient-groups.test.ts Expected: FAIL — cannot resolve ./recipient-groups.

  • Step 3: Write the implementation

Create packages/lib/utils/recipient-groups.ts:

import type { Recipient } from '@prisma/client';

import { isCcRecipient } from './recipients';

/**
 * A recipient "step" is the set of non-CC recipients sharing a signing order.
 * A step with 2 or more members is a "signing group": members may act in any
 * order among themselves, and the next step only unlocks once every member of
 * the group has completed their action.
 */

type GroupableRecipient = Pick<Recipient, 'role'> & {
  signingOrder?: number | null;
};

export type RecipientStep<T> = {
  /**
   * The signing order shared by all members of the step.
   */
  order: number;
  members: T[];
};

const UNORDERED = Number.MAX_SAFE_INTEGER;

const effectiveOrder = (recipient: { signingOrder?: number | null }) => recipient.signingOrder ?? UNORDERED;

/**
 * Derives the ordered list of steps from a list of recipients.
 *
 * - Non-CC recipients sharing a signing order form one step.
 * - Recipients without a signing order share a single tail step.
 * - CC recipients are returned separately and never belong to a step.
 */
export const groupRecipientsBySigningOrder = <T extends GroupableRecipient>(recipients: T[]) => {
  const ccRecipients = recipients.filter((recipient) => isCcRecipient(recipient));
  const nonCcRecipients = recipients.filter((recipient) => !isCcRecipient(recipient));

  const membersByOrder = new Map<number, T[]>();

  for (const recipient of nonCcRecipients) {
    const order = effectiveOrder(recipient);
    const members = membersByOrder.get(order) ?? [];

    members.push(recipient);
    membersByOrder.set(order, members);
  }

  const steps: RecipientStep<T>[] = [...membersByOrder.entries()]
    .sort(([orderA], [orderB]) => orderA - orderB)
    .map(([order, members]) => ({ order, members }));

  return { steps, ccRecipients };
};

/**
 * Dense-renumbers steps to 1..K while preserving groups (duplicate orders).
 *
 * Steps containing a locked recipient (per `canUpdateRecipient`) keep the
 * locked recipient's persisted order, and editable steps never collide into a
 * locked step's number.
 *
 * CC recipients get an undefined signing order and move to the tail. The
 * returned array is re-ordered by step sequence.
 */
export const normalizeGroupedSigningOrders = <T extends GroupableRecipient>(
  recipients: T[],
  canUpdateRecipient: (recipient: T) => boolean = () => true,
): Array<T & { signingOrder?: number }> => {
  const { steps, ccRecipients } = groupRecipientsBySigningOrder(recipients);

  const lockedOrderByStepIndex = new Map<number, number>();

  steps.forEach((step, index) => {
    const lockedMember = step.members.find((member) => !canUpdateRecipient(member));

    if (lockedMember && typeof lockedMember.signingOrder === 'number') {
      lockedOrderByStepIndex.set(index, lockedMember.signingOrder);
    }
  });

  const reservedOrders = new Set(lockedOrderByStepIndex.values());
  const normalizedSteps: RecipientStep<T>[] = [];

  let nextOrder = 1;

  steps.forEach((step, index) => {
    const lockedOrder = lockedOrderByStepIndex.get(index);

    if (lockedOrder !== undefined) {
      normalizedSteps.push({ order: lockedOrder, members: step.members });
      nextOrder = Math.max(nextOrder, lockedOrder + 1);

      return;
    }

    while (reservedOrders.has(nextOrder)) {
      nextOrder += 1;
    }

    normalizedSteps.push({ order: nextOrder, members: step.members });
    nextOrder += 1;
  });

  return [
    ...normalizedSteps.flatMap((step) => step.members.map((member) => ({ ...member, signingOrder: step.order }))),
    ...ccRecipients.map((recipient) => ({ ...recipient, signingOrder: undefined })),
  ];
};
  • Step 4: Run tests to verify they pass

Run: npm run test -w @documenso/lib -- utils/recipient-groups.test.ts Expected: PASS (7 tests).

  • Step 5: Commit
git add packages/lib/utils/recipient-groups.ts packages/lib/utils/recipient-groups.test.ts
git commit -m "feat: add recipient signing group derivation and normalization"

Task 2: Editor step operations

Files:

  • Modify: packages/lib/utils/recipient-groups.ts (append)

  • Modify: packages/lib/utils/recipient-groups.test.ts (append)

  • Step 1: Write the failing tests

Append to packages/lib/utils/recipient-groups.test.ts (add mergeSteps, moveRecipientToStep, extractRecipientToNewStep, reorderStep, ungroupStep to the existing import from ./recipient-groups):

const makeSigners = () => [
  { formId: 'a', role: RecipientRole.SIGNER, signingOrder: 1 },
  { formId: 'b', role: RecipientRole.SIGNER, signingOrder: 2 },
  { formId: 'c', role: RecipientRole.SIGNER, signingOrder: 3 },
  { formId: 'd', role: RecipientRole.SIGNER, signingOrder: 4 },
];

const ordersOf = (signers: Array<{ formId: string; signingOrder?: number }>) =>
  signers.map((signer) => [signer.formId, signer.signingOrder]);

describe('mergeSteps', () => {
  it('merges all members of the source step into the target step', () => {
    const merged = mergeSteps(makeSigners(), 2, 1);

    expect(ordersOf(merged)).toEqual([
      ['a', 1],
      ['b', 2],
      ['c', 2],
      ['d', 3],
    ]);
  });

  it('merges a whole group into another step', () => {
    const signers = [
      { formId: 'a', role: RecipientRole.SIGNER, signingOrder: 1 },
      { formId: 'b', role: RecipientRole.SIGNER, signingOrder: 2 },
      { formId: 'c', role: RecipientRole.SIGNER, signingOrder: 2 },
      { formId: 'd', role: RecipientRole.SIGNER, signingOrder: 3 },
    ];

    const merged = mergeSteps(signers, 1, 2);

    expect(ordersOf(merged)).toEqual([
      ['a', 1],
      ['d', 2],
      ['b', 2],
      ['c', 2],
    ]);
  });

  it('returns the input unchanged for an invalid step index', () => {
    const signers = makeSigners();

    expect(mergeSteps(signers, 7, 1)).toEqual(signers);
  });
});

describe('moveRecipientToStep', () => {
  it('appends the recipient to the target step members', () => {
    const moved = moveRecipientToStep(makeSigners(), 'a', 2);

    expect(ordersOf(moved)).toEqual([
      ['b', 1],
      ['c', 2],
      ['a', 2],
      ['d', 3],
    ]);
  });

  it('dissolves a group of two when one member joins another step', () => {
    const signers = [
      { formId: 'a', role: RecipientRole.SIGNER, signingOrder: 1 },
      { formId: 'b', role: RecipientRole.SIGNER, signingOrder: 1 },
      { formId: 'c', role: RecipientRole.SIGNER, signingOrder: 2 },
    ];

    const moved = moveRecipientToStep(signers, 'b', 1);

    expect(ordersOf(moved)).toEqual([
      ['a', 1],
      ['c', 2],
      ['b', 2],
    ]);
  });

  it('is a no-op when the recipient is already a member of the target step', () => {
    const signers = makeSigners();

    expect(ordersOf(moveRecipientToStep(signers, 'b', 1))).toEqual(ordersOf(signers));
  });
});

describe('extractRecipientToNewStep', () => {
  it('extracts a group member into its own step at the given gap', () => {
    const signers = [
      { formId: 'a', role: RecipientRole.SIGNER, signingOrder: 1 },
      { formId: 'b', role: RecipientRole.SIGNER, signingOrder: 2 },
      { formId: 'c', role: RecipientRole.SIGNER, signingOrder: 2 },
      { formId: 'd', role: RecipientRole.SIGNER, signingOrder: 3 },
    ];

    // Gap index 3 = before step index 3 does not exist here (only 3 steps), so
    // use gap 2 = before the step containing 'd'.
    const extracted = extractRecipientToNewStep(signers, 'c', 2);

    expect(ordersOf(extracted)).toEqual([
      ['a', 1],
      ['b', 2],
      ['c', 3],
      ['d', 4],
    ]);
  });

  it('extracts to the end for an out-of-bounds gap index', () => {
    const signers = [
      { formId: 'a', role: RecipientRole.SIGNER, signingOrder: 1 },
      { formId: 'b', role: RecipientRole.SIGNER, signingOrder: 1 },
      { formId: 'c', role: RecipientRole.SIGNER, signingOrder: 2 },
    ];

    const extracted = extractRecipientToNewStep(signers, 'a', 99);

    expect(ordersOf(extracted)).toEqual([
      ['b', 1],
      ['c', 2],
      ['a', 3],
    ]);
  });

  it('is a no-op when a solo recipient is dropped into an adjacent gap', () => {
    const signers = makeSigners();

    expect(ordersOf(extractRecipientToNewStep(signers, 'b', 1))).toEqual(ordersOf(signers));
    expect(ordersOf(extractRecipientToNewStep(signers, 'b', 2))).toEqual(ordersOf(signers));
  });
});

describe('reorderStep', () => {
  it('moves a whole group to a new position', () => {
    const signers = [
      { formId: 'a', role: RecipientRole.SIGNER, signingOrder: 1 },
      { formId: 'b', role: RecipientRole.SIGNER, signingOrder: 2 },
      { formId: 'c', role: RecipientRole.SIGNER, signingOrder: 2 },
      { formId: 'd', role: RecipientRole.SIGNER, signingOrder: 3 },
    ];

    const reordered = reorderStep(signers, 1, 2);

    expect(ordersOf(reordered)).toEqual([
      ['a', 1],
      ['d', 2],
      ['b', 3],
      ['c', 3],
    ]);
  });

  it('keeps a locked step number anchored while others flow around it', () => {
    const signers = [
      { formId: 'locked', role: RecipientRole.SIGNER, signingOrder: 1 },
      { formId: 'b', role: RecipientRole.SIGNER, signingOrder: 2 },
      { formId: 'c', role: RecipientRole.SIGNER, signingOrder: 3 },
    ];

    const reordered = reorderStep(signers, 1, 2, (r) => r.formId !== 'locked');

    expect(ordersOf(reordered)).toEqual([
      ['locked', 1],
      ['c', 2],
      ['b', 3],
    ]);
  });
});

describe('ungroupStep', () => {
  it('splits a group into consecutive standalone steps preserving relative order', () => {
    const signers = [
      { formId: 'a', role: RecipientRole.SIGNER, signingOrder: 1 },
      { formId: 'b', role: RecipientRole.SIGNER, signingOrder: 2 },
      { formId: 'c', role: RecipientRole.SIGNER, signingOrder: 2 },
      { formId: 'd', role: RecipientRole.SIGNER, signingOrder: 3 },
    ];

    const ungrouped = ungroupStep(signers, 1);

    expect(ordersOf(ungrouped)).toEqual([
      ['a', 1],
      ['b', 2],
      ['c', 3],
      ['d', 4],
    ]);
  });

  it('is a no-op on a step with a single member', () => {
    const signers = makeSigners();

    expect(ordersOf(ungroupStep(signers, 0))).toEqual(ordersOf(signers));
  });
});
  • Step 2: Run tests to verify they fail

Run: npm run test -w @documenso/lib -- utils/recipient-groups.test.ts Expected: FAIL — mergeSteps (etc.) not exported.

  • Step 3: Write the implementation

Append to packages/lib/utils/recipient-groups.ts:

type EditorRecipient = GroupableRecipient & { formId: string };

/**
 * Merges all members of the source step into the target step.
 */
export const mergeSteps = <T extends EditorRecipient>(
  recipients: T[],
  sourceStepIndex: number,
  targetStepIndex: number,
  canUpdateRecipient?: (recipient: T) => boolean,
): Array<T & { signingOrder?: number }> => {
  const { steps } = groupRecipientsBySigningOrder(recipients);

  const sourceStep = steps[sourceStepIndex];
  const targetStep = steps[targetStepIndex];

  if (!sourceStep || !targetStep || sourceStepIndex === targetStepIndex) {
    return recipients;
  }

  const sourceFormIds = new Set(sourceStep.members.map((member) => member.formId));

  const updated = recipients.map((recipient) =>
    sourceFormIds.has(recipient.formId) ? { ...recipient, signingOrder: targetStep.order } : recipient,
  );

  return normalizeGroupedSigningOrders(updated, canUpdateRecipient);
};

/**
 * Moves a single recipient into the target step (joins the group).
 */
export const moveRecipientToStep = <T extends EditorRecipient>(
  recipients: T[],
  formId: string,
  targetStepIndex: number,
  canUpdateRecipient?: (recipient: T) => boolean,
): Array<T & { signingOrder?: number }> => {
  const { steps } = groupRecipientsBySigningOrder(recipients);

  const targetStep = steps[targetStepIndex];
  const mover = recipients.find((recipient) => recipient.formId === formId);

  if (!targetStep || !mover || isCcRecipient(mover)) {
    return recipients;
  }

  if (targetStep.members.some((member) => member.formId === formId)) {
    return recipients;
  }

  const remaining = recipients.filter((recipient) => recipient.formId !== formId);
  const lastMemberFormId = targetStep.members[targetStep.members.length - 1].formId;
  const insertAfterIndex = remaining.findIndex((recipient) => recipient.formId === lastMemberFormId);

  const updated = [
    ...remaining.slice(0, insertAfterIndex + 1),
    { ...mover, signingOrder: targetStep.order },
    ...remaining.slice(insertAfterIndex + 1),
  ];

  return normalizeGroupedSigningOrders(updated, canUpdateRecipient);
};

/**
 * Extracts a recipient into its own standalone step at the given gap position
 * (gap N sits before step N; an out-of-bounds gap appends to the end).
 */
export const extractRecipientToNewStep = <T extends EditorRecipient>(
  recipients: T[],
  formId: string,
  insertStepIndex: number,
  canUpdateRecipient?: (recipient: T) => boolean,
): Array<T & { signingOrder?: number }> => {
  const { steps } = groupRecipientsBySigningOrder(recipients);

  const mover = recipients.find((recipient) => recipient.formId === formId);

  if (!mover || isCcRecipient(mover)) {
    return recipients;
  }

  const currentStepIndex = steps.findIndex((step) => step.members.some((member) => member.formId === formId));
  const isSoloStep = currentStepIndex !== -1 && steps[currentStepIndex].members.length === 1;

  // Dropping a solo step into the gap directly above or below itself is a no-op.
  if (isSoloStep && (insertStepIndex === currentStepIndex || insertStepIndex === currentStepIndex + 1)) {
    return recipients;
  }

  const insertOrder =
    insertStepIndex >= steps.length
      ? (steps[steps.length - 1]?.order ?? 0) + 1
      : steps[insertStepIndex].order - 0.5;

  const updated = recipients.map((recipient) =>
    recipient.formId === formId ? { ...recipient, signingOrder: insertOrder } : recipient,
  );

  return normalizeGroupedSigningOrders(updated, canUpdateRecipient);
};

/**
 * Moves a whole step (group) to a new position in the step sequence.
 *
 * Locked steps keep their members' persisted orders untouched (the sequence
 * flows around them), and editable steps never collide onto a locked anchor —
 * that would accidentally merge them during re-derivation.
 */
export const reorderStep = <T extends EditorRecipient>(
  recipients: T[],
  fromStepIndex: number,
  toStepIndex: number,
  canUpdateRecipient: (recipient: T) => boolean = () => true,
): Array<T & { signingOrder?: number }> => {
  const { steps, ccRecipients } = groupRecipientsBySigningOrder(recipients);

  if (!steps[fromStepIndex] || fromStepIndex === toStepIndex) {
    return recipients;
  }

  const reorderedSteps = [...steps];
  const [movedStep] = reorderedSteps.splice(fromStepIndex, 1);

  reorderedSteps.splice(Math.min(toStepIndex, reorderedSteps.length), 0, movedStep);

  const isStepLocked = (step: RecipientStep<T>) => step.members.some((member) => !canUpdateRecipient(member));

  const lockedAnchors = new Set(reorderedSteps.filter((step) => isStepLocked(step)).map((step) => step.order));

  const updated = [
    ...reorderedSteps.flatMap((step, index) => {
      if (isStepLocked(step)) {
        return step.members;
      }

      const tempOrder = lockedAnchors.has(index + 1) ? index + 1.5 : index + 1;

      return step.members.map((member) => ({ ...member, signingOrder: tempOrder }));
    }),
    ...ccRecipients,
  ];

  return normalizeGroupedSigningOrders(updated, canUpdateRecipient);
};

/**
 * Dissolves a group into consecutive standalone steps preserving relative order.
 */
export const ungroupStep = <T extends EditorRecipient>(
  recipients: T[],
  stepIndex: number,
  canUpdateRecipient?: (recipient: T) => boolean,
): Array<T & { signingOrder?: number }> => {
  const { steps } = groupRecipientsBySigningOrder(recipients);

  const step = steps[stepIndex];

  if (!step || step.members.length < 2) {
    return recipients;
  }

  const offsetByFormId = new Map(step.members.map((member, index) => [member.formId, index]));

  const updated = recipients.map((recipient) => {
    const offset = offsetByFormId.get(recipient.formId);

    if (offset === undefined) {
      return recipient;
    }

    return { ...recipient, signingOrder: step.order + offset / (step.members.length + 1) };
  });

  return normalizeGroupedSigningOrders(updated, canUpdateRecipient);
};
  • Step 4: Run tests to verify they pass

Run: npm run test -w @documenso/lib -- utils/recipient-groups.test.ts Expected: PASS.

  • Step 5: Commit
git add packages/lib/utils/recipient-groups.ts packages/lib/utils/recipient-groups.test.ts
git commit -m "feat: add signing group editor operations"

Task 3: Turn predicate, first-group filter, dictation helper

Files:

  • Modify: packages/lib/utils/recipient-groups.ts (append)

  • Modify: packages/lib/utils/recipient-groups.test.ts (append)

  • Step 1: Write the failing tests

Append to packages/lib/utils/recipient-groups.test.ts (extend the ./recipient-groups import with filterRecipientsInFirstSigningGroup, getDictatableNextRecipient, isRecipientTurnBySigningOrder; extend the @prisma/client import with SigningStatus):

describe('isRecipientTurnBySigningOrder', () => {
  const recipient = (
    id: number,
    signingOrder: number | null,
    signingStatus: SigningStatus,
    role: RecipientRole = RecipientRole.SIGNER,
  ) => ({ id, signingOrder, signingStatus, role });

  it('allows both members of the active group regardless of member order', () => {
    const recipients = [
      recipient(1, 1, SigningStatus.SIGNED),
      recipient(2, 2, SigningStatus.NOT_SIGNED),
      recipient(3, 2, SigningStatus.NOT_SIGNED),
      recipient(4, 3, SigningStatus.NOT_SIGNED),
    ];

    expect(isRecipientTurnBySigningOrder(recipients, recipients[1])).toBe(true);
    expect(isRecipientTurnBySigningOrder(recipients, recipients[2])).toBe(true);
    expect(isRecipientTurnBySigningOrder(recipients, recipients[3])).toBe(false);
  });

  it('blocks later steps until every group member has signed', () => {
    const recipients = [
      recipient(1, 1, SigningStatus.SIGNED),
      recipient(2, 2, SigningStatus.SIGNED),
      recipient(3, 2, SigningStatus.NOT_SIGNED),
      recipient(4, 3, SigningStatus.NOT_SIGNED),
    ];

    expect(isRecipientTurnBySigningOrder(recipients, recipients[3])).toBe(false);
  });

  it('treats a rejected recipient in an earlier step as blocking', () => {
    const recipients = [
      recipient(1, 1, SigningStatus.REJECTED),
      recipient(2, 2, SigningStatus.NOT_SIGNED),
    ];

    expect(isRecipientTurnBySigningOrder(recipients, recipients[1])).toBe(false);
  });

  it('ignores CC recipients entirely', () => {
    const recipients = [
      recipient(1, 1, SigningStatus.NOT_SIGNED, RecipientRole.CC),
      recipient(2, 2, SigningStatus.NOT_SIGNED),
    ];

    expect(isRecipientTurnBySigningOrder(recipients, recipients[1])).toBe(true);
  });

  it('treats recipients without a signing order as a parallel tail group', () => {
    const recipients = [
      recipient(1, 1, SigningStatus.SIGNED),
      recipient(2, null, SigningStatus.NOT_SIGNED),
      recipient(3, null, SigningStatus.NOT_SIGNED),
    ];

    expect(isRecipientTurnBySigningOrder(recipients, recipients[1])).toBe(true);
    expect(isRecipientTurnBySigningOrder(recipients, recipients[2])).toBe(true);
  });
});

describe('filterRecipientsInFirstSigningGroup', () => {
  it('returns every pending recipient sharing the lowest order', () => {
    const pending = [
      { id: 3, signingOrder: 2 },
      { id: 4, signingOrder: 2 },
      { id: 5, signingOrder: 3 },
    ];

    expect(filterRecipientsInFirstSigningGroup(pending).map((r) => r.id)).toEqual([3, 4]);
  });

  it('returns an empty array for no pending recipients', () => {
    expect(filterRecipientsInFirstSigningGroup([])).toEqual([]);
  });
});

describe('getDictatableNextRecipient', () => {
  const recipient = (
    id: number,
    signingOrder: number | null,
    signingStatus: SigningStatus,
    role: RecipientRole = RecipientRole.SIGNER,
  ) => ({ id, signingOrder, signingStatus, role });

  it('returns the next recipient when current is last of their step and next step is a single recipient', () => {
    const recipients = [
      recipient(1, 1, SigningStatus.SIGNED),
      recipient(2, 2, SigningStatus.NOT_SIGNED),
      recipient(3, 3, SigningStatus.NOT_SIGNED),
    ];

    expect(getDictatableNextRecipient({ recipients, currentRecipientId: 2 })?.id).toBe(3);
  });

  it('returns null while a group peer is still unsigned', () => {
    const recipients = [
      recipient(1, 1, SigningStatus.NOT_SIGNED),
      recipient(2, 1, SigningStatus.NOT_SIGNED),
      recipient(3, 2, SigningStatus.NOT_SIGNED),
    ];

    expect(getDictatableNextRecipient({ recipients, currentRecipientId: 1 })).toBeNull();
  });

  it('returns the next single recipient once all group peers signed', () => {
    const recipients = [
      recipient(1, 1, SigningStatus.SIGNED),
      recipient(2, 1, SigningStatus.NOT_SIGNED),
      recipient(3, 2, SigningStatus.NOT_SIGNED),
    ];

    expect(getDictatableNextRecipient({ recipients, currentRecipientId: 2 })?.id).toBe(3);
  });

  it('returns null when the next step is a group', () => {
    const recipients = [
      recipient(1, 1, SigningStatus.NOT_SIGNED),
      recipient(2, 2, SigningStatus.NOT_SIGNED),
      recipient(3, 2, SigningStatus.NOT_SIGNED),
    ];

    expect(getDictatableNextRecipient({ recipients, currentRecipientId: 1 })).toBeNull();
  });

  it('returns null when there is no later step, for CC targets, or unknown recipients', () => {
    const recipients = [
      recipient(1, 1, SigningStatus.NOT_SIGNED),
      recipient(2, null, SigningStatus.NOT_SIGNED, RecipientRole.CC),
    ];

    expect(getDictatableNextRecipient({ recipients, currentRecipientId: 1 })).toBeNull();
    expect(getDictatableNextRecipient({ recipients, currentRecipientId: 999 })).toBeNull();
  });
});
  • Step 2: Run tests to verify they fail

Run: npm run test -w @documenso/lib -- utils/recipient-groups.test.ts Expected: FAIL — missing exports.

  • Step 3: Write the implementation

Append to packages/lib/utils/recipient-groups.ts (add SigningStatus to a new value import: import { SigningStatus } from '@prisma/client'; at the top, keeping the existing type import):

type SignableRecipient = Pick<Recipient, 'role' | 'signingStatus'> & {
  signingOrder?: number | null;
};

/**
 * Whether it is the recipient's turn to act under SEQUENTIAL signing.
 *
 * A recipient may act iff no non-CC recipient with a strictly lower signing
 * order is still unsigned (rejected counts as unsigned/blocking). Recipients
 * sharing a signing order never block each other.
 *
 * Callers are responsible for checking the document is in SEQUENTIAL mode.
 */
export const isRecipientTurnBySigningOrder = <T extends SignableRecipient>(
  recipients: T[],
  currentRecipient: { signingOrder?: number | null },
): boolean => {
  const currentOrder = effectiveOrder(currentRecipient);

  return !recipients.some(
    (recipient) =>
      !isCcRecipient(recipient) &&
      recipient.signingStatus !== SigningStatus.SIGNED &&
      effectiveOrder(recipient) < currentOrder,
  );
};

/**
 * Returns every pending recipient sharing the lowest pending signing order —
 * the "active group". Callers pass an already-filtered pending list.
 */
export const filterRecipientsInFirstSigningGroup = <T extends { signingOrder?: number | null }>(
  pendingRecipients: T[],
): T[] => {
  if (pendingRecipients.length === 0) {
    return [];
  }

  const minOrder = Math.min(...pendingRecipients.map((recipient) => effectiveOrder(recipient)));

  return pendingRecipients.filter((recipient) => effectiveOrder(recipient) === minOrder);
};

/**
 * The single recipient that the current recipient may dictate (rename) on
 * completion, or null when dictation does not apply:
 *
 * - the current recipient must be the last unsigned member of their step, and
 * - the next step must contain exactly one recipient.
 */
export const getDictatableNextRecipient = <T extends SignableRecipient & Pick<Recipient, 'id'>>({
  recipients,
  currentRecipientId,
}: {
  recipients: T[];
  currentRecipientId: number;
}): T | null => {
  const currentRecipient = recipients.find((recipient) => recipient.id === currentRecipientId);

  if (!currentRecipient || isCcRecipient(currentRecipient)) {
    return null;
  }

  const currentOrder = effectiveOrder(currentRecipient);

  const hasUnsignedPeers = recipients.some(
    (recipient) =>
      recipient.id !== currentRecipientId &&
      !isCcRecipient(recipient) &&
      effectiveOrder(recipient) === currentOrder &&
      recipient.signingStatus !== SigningStatus.SIGNED,
  );

  if (hasUnsignedPeers) {
    return null;
  }

  const laterRecipients = recipients.filter(
    (recipient) => !isCcRecipient(recipient) && effectiveOrder(recipient) > currentOrder,
  );

  const nextStep = filterRecipientsInFirstSigningGroup(laterRecipients);

  if (nextStep.length !== 1) {
    return null;
  }

  return nextStep[0];
};
  • Step 4: Run tests to verify they pass

Run: npm run test -w @documenso/lib -- utils/recipient-groups.test.ts Expected: PASS.

  • Step 5: Commit
git add packages/lib/utils/recipient-groups.ts packages/lib/utils/recipient-groups.test.ts
git commit -m "feat: add group-aware turn and dictation helpers"

Task 4: Group-aware isAssistantLastSigner

Files:

  • Modify: packages/lib/utils/recipients.ts:25-30

  • Modify: packages/lib/utils/recipients.test.ts

  • Step 1: Write the failing tests

Append inside the existing describe in packages/lib/utils/recipients.test.ts:

  it('detects an assistant anywhere in the last signing step (groups)', () => {
    expect(
      isAssistantLastSigner([
        { role: RecipientRole.SIGNER, signingOrder: 1 },
        { role: RecipientRole.ASSISTANT, signingOrder: 2 },
        { role: RecipientRole.SIGNER, signingOrder: 2 },
      ]),
    ).toBe(true);

    expect(
      isAssistantLastSigner([
        { role: RecipientRole.ASSISTANT, signingOrder: 1 },
        { role: RecipientRole.SIGNER, signingOrder: 1 },
        { role: RecipientRole.SIGNER, signingOrder: 2 },
      ]),
    ).toBe(false);
  });
  • Step 2: Run tests to verify they fail

Run: npm run test -w @documenso/lib -- utils/recipients.test.ts Expected: FAIL — first assertion returns false (current implementation checks the last array element; the assistant with order 2 is not last in the array).

  • Step 3: Replace the implementation

In packages/lib/utils/recipients.ts, replace the existing isAssistantLastSigner (lines 25-30) with:

/**
 * Whether an assistant sits in the last signing step (nobody after them to assist).
 *
 * Falls back to a positional check when no recipient carries a signing order.
 */
export const isAssistantLastSigner = (
  recipients: Array<Pick<Recipient, 'role'> & { signingOrder?: number | null }>,
) => {
  const nonCcRecipients = recipients.filter((recipient) => !isCcRecipient(recipient));

  if (nonCcRecipients.length === 0) {
    return false;
  }

  const hasAnySigningOrder = nonCcRecipients.some((recipient) => typeof recipient.signingOrder === 'number');

  if (!hasAnySigningOrder) {
    return nonCcRecipients[nonCcRecipients.length - 1]?.role === RecipientRole.ASSISTANT;
  }

  const maxOrder = Math.max(
    ...nonCcRecipients.map((recipient) => recipient.signingOrder ?? Number.MAX_SAFE_INTEGER),
  );

  return nonCcRecipients.some(
    (recipient) =>
      (recipient.signingOrder ?? Number.MAX_SAFE_INTEGER) === maxOrder &&
      recipient.role === RecipientRole.ASSISTANT,
  );
};
  • Step 4: Run tests to verify they pass

Run: npm run test -w @documenso/lib -- utils/recipients.test.ts Expected: PASS — both the new test and the two existing positional tests (checks whether the last non-CC recipient is an assistant).

  • Step 5: Commit
git add packages/lib/utils/recipients.ts packages/lib/utils/recipients.test.ts
git commit -m "feat: make assistant-last-signer check signing-group aware"

Task 5: Server turn checks use the shared predicate

Files:

  • Modify: packages/lib/server-only/recipient/get-is-recipient-turn.ts

  • Modify: packages/lib/server-only/envelope/get-envelope-for-recipient-signing.ts:263-279

  • Step 1: Rewrite get-is-recipient-turn.ts

Replace the entire file content with:

import { prisma } from '@documenso/prisma';
import { DocumentSigningOrder, EnvelopeType } from '@prisma/client';

import { isRecipientTurnBySigningOrder } from '../../utils/recipient-groups';

export type GetIsRecipientTurnOptions = {
  token: string;
};

export async function getIsRecipientsTurnToSign({ token }: GetIsRecipientTurnOptions) {
  const envelope = await prisma.envelope.findFirstOrThrow({
    where: {
      type: EnvelopeType.DOCUMENT,
      recipients: {
        some: {
          token,
        },
      },
    },
    include: {
      documentMeta: true,
      recipients: true,
    },
  });

  if (envelope.documentMeta?.signingOrder !== DocumentSigningOrder.SEQUENTIAL) {
    return true;
  }

  const currentRecipient = envelope.recipients.find((recipient) => recipient.token === token);

  if (!currentRecipient) {
    return false;
  }

  return isRecipientTurnBySigningOrder(envelope.recipients, currentRecipient);
}
  • Step 2: Replace the inline copy in get-envelope-for-recipient-signing.ts

Replace lines 263-279 (the let isRecipientsTurn = true; block through the closing brace of the for loop's if) with:

  const isRecipientsTurn =
    envelope.documentMeta.signingOrder !== DocumentSigningOrder.SEQUENTIAL ||
    isRecipientTurnBySigningOrder(envelope.recipients, recipient);

Add the import:

import { isRecipientTurnBySigningOrder } from '../../utils/recipient-groups';

Also remove the now-unneeded orderBy: { signingOrder: 'asc' } on the recipients include (lines 197-199) — the predicate is order-independent. Then search the file for RecipientRole — if it is no longer referenced after this change, remove it from the @prisma/client import (keep SigningStatus, still used at lines 296-297).

  • Step 3: Type-check

Run: npx tsc --noEmit (workdir packages/lib) Expected: no errors in the two modified files.

  • Step 4: Commit
git add packages/lib/server-only/recipient/get-is-recipient-turn.ts packages/lib/server-only/envelope/get-envelope-for-recipient-signing.ts
git commit -m "feat: use group-aware turn checks for sequential signing"

Task 6: send-document.ts notifies the whole first group

Files:

  • Modify: packages/lib/server-only/document/send-document.ts:150-157

  • Step 1: Replace the single-recipient slice

Replace:

  let recipientsToNotify = envelope.recipients;

  if (signingOrder === DocumentSigningOrder.SEQUENTIAL) {
    // Get the currently active recipient.
    recipientsToNotify = envelope.recipients
      .filter((r) => r.signingStatus === SigningStatus.NOT_SIGNED && r.role !== RecipientRole.CC)
      .slice(0, 1);
  }

with:

  let recipientsToNotify = envelope.recipients;

  if (signingOrder === DocumentSigningOrder.SEQUENTIAL) {
    // Get the currently active signing group. Recipients sharing the lowest
    // pending signing order act in parallel within their group.
    recipientsToNotify = filterRecipientsInFirstSigningGroup(
      envelope.recipients.filter((r) => r.signingStatus === SigningStatus.NOT_SIGNED && r.role !== RecipientRole.CC),
    );
  }

Add the import:

import { filterRecipientsInFirstSigningGroup } from '../../utils/recipient-groups';
  • Step 2: Type-check

Run: npx tsc --noEmit (workdir packages/lib) Expected: no new errors.

  • Step 3: Commit
git add packages/lib/server-only/document/send-document.ts
git commit -m "feat: notify all members of the first signing group on send"

Task 7: complete-document-with-token.ts group advance + dictation gating

Files:

  • Modify: packages/lib/server-only/document/complete-document-with-token.ts:368-460

  • Step 1: Add sendStatus to the pending query select

In the pendingRecipients query (lines 368-388), extend the select:

    select: {
      id: true,
      signingOrder: true,
      name: true,
      email: true,
      role: true,
      sendStatus: true,
    },
  • Step 2: Replace the SEQUENTIAL advance branch

Replace the block from if (envelope.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL) { (line 399) through its closing brace (line 459) with:

    if (envelope.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL) {
      // The active group: every pending recipient sharing the lowest pending
      // signing order. Members already activated (sendStatus SENT) are group
      // peers who were notified earlier — activating only fresh members means
      // a mid-group completion is a no-op and a step transition activates the
      // whole next group at once.
      const nextGroup = filterRecipientsInFirstSigningGroup(pendingRecipients);
      const recipientsToActivate = nextGroup.filter((r) => r.sendStatus !== SendStatus.SENT);

      // Dictation only applies when advancing to a single-recipient step.
      const canDictateNextSigner =
        Boolean(nextSigner) &&
        Boolean(envelope.documentMeta?.allowDictateNextSigner) &&
        nextGroup.length === 1 &&
        recipientsToActivate.length === 1;

      await prisma.$transaction(async (tx) => {
        if (canDictateNextSigner && nextSigner) {
          const [nextRecipient] = recipientsToActivate;

          await tx.documentAuditLog.create({
            data: createDocumentAuditLogData({
              type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
              envelopeId: envelope.id,
              user: {
                name: recipientName,
                email: recipientEmail,
              },
              requestMetadata,
              data: {
                recipientEmail: nextRecipient.email,
                recipientName: nextRecipient.name,
                recipientId: nextRecipient.id,
                recipientRole: nextRecipient.role,
                changes: [
                  {
                    type: RECIPIENT_DIFF_TYPE.NAME,
                    from: nextRecipient.name,
                    to: nextSigner.name,
                  },
                  {
                    type: RECIPIENT_DIFF_TYPE.EMAIL,
                    from: nextRecipient.email,
                    to: nextSigner.email,
                  },
                ],
              },
            }),
          });
        }

        for (const nextRecipient of recipientsToActivate) {
          await tx.recipient.update({
            where: { id: nextRecipient.id },
            data: {
              sendStatus: SendStatus.SENT,
              sentAt: new Date(),
              ...(canDictateNextSigner && nextSigner
                ? {
                    name: nextSigner.name,
                    email: nextSigner.email,
                  }
                : {}),
            },
          });
        }
      });

      for (const nextRecipient of recipientsToActivate) {
        await jobs.triggerJob({
          name: 'send.signing.requested.email',
          payload: {
            userId: envelope.userId,
            documentId: legacyDocumentId,
            recipientId: nextRecipient.id,
            requestMetadata,
          },
        });
      }
    }

Add the import:

import { filterRecipientsInFirstSigningGroup } from '../../utils/recipient-groups';

(SendStatus is already imported in this file.)

  • Step 3: Type-check

Run: npx tsc --noEmit (workdir packages/lib) Expected: no new errors.

  • Step 4: Commit
git add packages/lib/server-only/document/complete-document-with-token.ts
git commit -m "feat: activate whole signing group on sequential advance"

Task 8: Direct-template dictation + getNextPendingRecipient

Files:

  • Modify: packages/lib/server-only/template/create-document-from-direct-template.ts:697

  • Modify: packages/lib/server-only/recipient/get-next-pending-recipient.ts

  • Step 1: Gate direct-template dictation to single-member next steps

In create-document-from-direct-template.ts, replace line 697:

      const nextRecipient = pendingRecipients[0];

with:

      const nextGroup = filterRecipientsInFirstSigningGroup(pendingRecipients);

      // Dictation only applies when the next step is a single recipient.
      const nextRecipient = nextGroup.length === 1 ? nextGroup[0] : null;

Add the import:

import { filterRecipientsInFirstSigningGroup } from '../../utils/recipient-groups';

(The following if (nextRecipient) { ... } block stays unchanged.)

  • Step 2: Rewrite get-next-pending-recipient.ts

Replace the entire file content with:

import { prisma } from '@documenso/prisma';
import { EnvelopeType } from '@prisma/client';

import { mapDocumentIdToSecondaryId } from '../../utils/envelope';
import { getDictatableNextRecipient } from '../../utils/recipient-groups';

export const getNextPendingRecipient = async ({
  documentId,
  currentRecipientId,
}: {
  documentId: number;
  currentRecipientId: number;
}) => {
  const recipients = await prisma.recipient.findMany({
    where: {
      envelope: {
        type: EnvelopeType.DOCUMENT,
        secondaryId: mapDocumentIdToSecondaryId(documentId),
      },
    },
  });

  const nextRecipient = getDictatableNextRecipient({ recipients, currentRecipientId });

  if (!nextRecipient) {
    return null;
  }

  return {
    ...nextRecipient,
    token: '',
  };
};
  • Step 3: Type-check

Run: npx tsc --noEmit (workdir packages/lib) Expected: no new errors.

  • Step 4: Commit
git add packages/lib/server-only/template/create-document-from-direct-template.ts packages/lib/server-only/recipient/get-next-pending-recipient.ts
git commit -m "feat: gate next-signer dictation to single-recipient steps"

Task 9: Assistant scope becomes strictly-greater (self preserved)

Files:

  • Modify: packages/lib/server-only/recipient/get-recipients-for-assistant.ts:23-29
  • Modify: packages/trpc/server/envelope-router/sign-envelope-field.ts:42-51

Grouped assistants must not act for their group peers (equal order). The current gte queries also match the assistant themself — that self-inclusion is load-bearing (assistants insert their own fields through these paths) and must be preserved explicitly.

  • Step 1: Update get-recipients-for-assistant.ts

Replace the where clause of the recipients query (lines 24-29):

    where: {
      envelopeId: assistant.envelopeId,
      OR: [
        // The assistant themself — they may have fields of their own.
        { id: assistant.id },
        // Grouped assistants only assist strictly later steps, never their
        // own group peers.
        { signingOrder: { gt: assistant.signingOrder ?? 0 } },
      ],
    },
  • Step 2: Update the assistant branch in sign-envelope-field.ts

Replace the assistant branch of the field.recipient condition (lines 42-51):

          ...(recipient.role === RecipientRole.ASSISTANT
            ? {
                signingStatus: {
                  not: SigningStatus.SIGNED,
                },
                envelopeId: recipient.envelopeId,
                OR: [
                  // The assistant's own fields.
                  { id: recipient.id },
                  // Fields of recipients in strictly later steps only.
                  { signingOrder: { gt: recipient.signingOrder ?? 0 } },
                ],
              }
            : {
                id: recipient.id,
              }),
  • Step 3: Type-check both packages

Run: npx tsc --noEmit (workdir packages/lib), then npx tsc --noEmit (workdir packages/trpc) Expected: no new errors.

  • Step 4: Commit
git add packages/lib/server-only/recipient/get-recipients-for-assistant.ts packages/trpc/server/envelope-router/sign-envelope-field.ts
git commit -m "feat: scope assistants to strictly later signing steps"

Task 10: Client dictation mirrors use getDictatableNextRecipient

Files:

  • Modify: apps/remix/app/components/general/document-signing/envelope-signing-provider.tsx:292-319
  • Modify: apps/remix/app/components/general/document-signing/document-signing-page-view-v1.tsx:145-171
  • Modify: apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx:217-247

All three files contain a near-identical nextRecipient useMemo (sort by order nulls-last then id, return sortedRecipients[currentIndex + 1]). Replace each body with the shared helper — the dictate form then only renders when the signer is the last unsigned member of their step and the next step has exactly one member.

  • Step 1: envelope-signing-provider.tsx

Replace the nextRecipient useMemo (lines 292-319) with:

  const nextRecipient = useMemo(() => {
    if (envelope.documentMeta.signingOrder !== 'SEQUENTIAL') {
      return null;
    }

    return getDictatableNextRecipient({
      recipients: envelope.recipients,
      currentRecipientId: recipient.id,
    });
  }, [envelope.documentMeta?.signingOrder, envelope.recipients, recipient.id]);

Add the import:

import { getDictatableNextRecipient } from '@documenso/lib/utils/recipient-groups';
  • Step 2: document-signing-page-view-v1.tsx

Replace the nextRecipient useMemo (lines 145-171) with:

  const nextRecipient = useMemo(() => {
    if (documentMeta?.signingOrder !== 'SEQUENTIAL') {
      return undefined;
    }

    return getDictatableNextRecipient({ recipients: allRecipients, currentRecipientId: recipient.id }) ?? undefined;
  }, [document.documentMeta?.signingOrder, allRecipients, recipient.id]);

Add the same import.

  • Step 3: direct-template-signing-form.tsx

Open the file and locate the nextRecipient useMemo (lines 217-247). Keep the existing early-return guard exactly as-is (it checks template.templateMeta?.signingOrder !== 'SEQUENTIAL' plus direct-recipient conditions at lines 218-225) and replace only the sorting/index logic below it with:

    return (
      getDictatableNextRecipient({
        recipients: template.recipients,
        currentRecipientId: directRecipient.id,
      }) ?? undefined
    );

Keep the dependency array unchanged. Add the same import.

  • Step 4: Type-check

Run: npx tsc --noEmit (workdir apps/remix) Expected: no new errors in the three modified files. If getDictatableNextRecipient's return type narrows the previous local type, keep the surrounding usages (nextRecipient.name / nextRecipient.email) working — the helper returns the full recipient object from the input array, so no shape changes.

  • Step 5: Commit
git add apps/remix/app/components/general/document-signing/envelope-signing-provider.tsx apps/remix/app/components/general/document-signing/document-signing-page-view-v1.tsx apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx
git commit -m "feat: gate dictation UI to single-recipient next steps"

Task 11: Editor lib support — grouped load, CSC validation, modifiability util

Files:

  • Modify: packages/lib/client-only/hooks/use-editor-recipients.ts

  • Modify: packages/lib/utils/recipients.ts (append)

  • Step 1: Load recipients with grouped normalization

In use-editor-recipients.ts line 104, the flat normalizer destroys persisted groups on load. Replace:

        ? normalizeRecipientSigningOrders(sortRecipientsForSigningOrder(formRecipients))

with:

        ? normalizeGroupedSigningOrders(sortRecipientsForSigningOrder(formRecipients))

Update the import at line 12: replace normalizeRecipientSigningOrders with normalizeGroupedSigningOrders from ../../utils/recipient-groups (keep isCcRecipient and sortRecipientsForSigningOrder from ../../utils/recipients):

import { normalizeGroupedSigningOrders } from '../../utils/recipient-groups';
import { isCcRecipient, sortRecipientsForSigningOrder } from '../../utils/recipients';
  • Step 2: Add the CSC no-duplicates rule

In the superRefine of ZEditorRecipientsFormSchema (after the existing assistant-role loop, before the closing });), append:

    const seenSigningOrders = new Set<number>();

    data.signers.forEach((signer, index) => {
      if (signer.role === RecipientRole.CC || typeof signer.signingOrder !== 'number') {
        return;
      }

      if (seenSigningOrders.has(signer.signingOrder)) {
        ctx.addIssue({
          code: z.ZodIssueCode.custom,
          message: 'CSC envelopes do not support recipient signing groups.',
          path: ['signers', index, 'signingOrder'],
        });
      }

      seenSigningOrders.add(signer.signingOrder);
    });
  • Step 3: Add 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';):

/**
 * Editor-level wrapper around `canRecipientBeModified`.
 *
 * Template recipients and unsaved (id-less) recipients can always be modified.
 */
export const canEditorRecipientBeModified = (
  envelope: Pick<TEditorEnvelope, 'type' | 'recipients' | 'fields'>,
  recipientId?: number,
) => {
  if (envelope.type === EnvelopeType.TEMPLATE) {
    return true;
  }

  if (recipientId === undefined) {
    return true;
  }

  const recipient = envelope.recipients.find((r) => r.id === recipientId);

  if (!recipient) {
    return false;
  }

  return canRecipientBeModified(recipient, envelope.fields);
};
  • Step 4: Type-check and run lib tests

Run: npx tsc --noEmit (workdir packages/lib), then npm run test -w @documenso/lib Expected: no errors, all tests pass.

  • Step 5: Commit
git add packages/lib/client-only/hooks/use-editor-recipients.ts packages/lib/utils/recipients.ts
git commit -m "feat: load editor recipients with group-preserving normalization"

Task 12: RecipientRow component

Files:

  • Create: apps/remix/app/components/general/envelope-editor/recipient-row.tsx

This is the row markup extracted from envelope-editor-recipient-form.tsx:845-1065, parameterized. No tests of its own (covered by E2E + tsc); the form keeps compiling because it is not rewired until Task 15.

  • Step 1: Create the component
import type { TEditorRecipientsFormSchema } from '@documenso/lib/client-only/hooks/use-editor-recipients';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { isCcRecipient } from '@documenso/lib/utils/recipients';
import { RecipientActionAuthSelect } from '@documenso/ui/components/recipient/recipient-action-auth-select';
import {
  RecipientAutoCompleteInput,
  type RecipientAutoCompleteOption,
} from '@documenso/ui/components/recipient/recipient-autocomplete-input';
import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { FormControl, FormField, FormItem, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import type { DraggableProvidedDragHandleProps } from '@hello-pangea/dnd';
import { useLingui } from '@lingui/react/macro';
import { EnvelopeType, type RecipientRole } from '@prisma/client';
import { GripVerticalIcon, TrashIcon } from 'lucide-react';
import { useFormContext } from 'react-hook-form';

type TEditorSigner = TEditorRecipientsFormSchema['signers'][number];

export type RecipientRowProps = {
  signerIndex: number;
  signer: TEditorSigner;
  stepCount: number;
  isSequential: boolean;
  isGrouped: boolean;
  isInputDisabled: boolean;
  canBeModified: boolean;
  isRemoveDisabled: boolean;
  showAdvancedSettings: boolean;
  dragHandleProps?: DraggableProvidedDragHandleProps | null;
  recipientSuggestions: RecipientAutoCompleteOption[];
  isLoadingSuggestions: boolean;
  onSigningOrderChange: (signerIndex: number, value: string) => void;
  onRoleChange: (signerIndex: number, role: RecipientRole) => void;
  onRemove: (signerIndex: number) => void;
  onAutoCompleteSelect: (signerIndex: number, suggestion: RecipientAutoCompleteOption) => void;
  onSearchQueryChange: (query: string) => void;
};

export const RecipientRow = ({
  signerIndex,
  signer,
  stepCount,
  isSequential,
  isGrouped,
  isInputDisabled,
  canBeModified,
  isRemoveDisabled,
  showAdvancedSettings,
  dragHandleProps,
  recipientSuggestions,
  isLoadingSuggestions,
  onSigningOrderChange,
  onRoleChange,
  onRemove,
  onAutoCompleteSelect,
  onSearchQueryChange,
}: RecipientRowProps) => {
  const { t } = useLingui();

  const { envelope, editorConfig } = useCurrentEnvelopeEditor();
  const organisation = useCurrentOrganisation();

  const form = useFormContext<TEditorRecipientsFormSchema>();

  const { isSubmitting } = form.formState;

  const isDirectRecipient =
    envelope.type === EnvelopeType.TEMPLATE &&
    envelope.directLink !== null &&
    signer.id === envelope.directLink.directTemplateRecipientId;

  const isFieldDisabled = isInputDisabled || isSubmitting || !canBeModified;

  const rowErrors = form.formState.errors.signers?.[signerIndex];

  return (
    <fieldset data-native-id={signer.id} disabled={isSubmitting || !canBeModified} className="py-1">
      <div className="flex flex-row items-center gap-x-2">
        {isSequential && !isCcRecipient(signer) && (
          <FormField
            control={form.control}
            name={`signers.${signerIndex}.signingOrder`}
            render={({ field }) => (
              <FormItem
                className={cn('mt-auto flex items-center gap-x-1 space-y-0', {
                  'mb-6': rowErrors && !rowErrors.signingOrder,
                })}
              >
                <span {...(dragHandleProps ?? {})} data-testid="recipient-row-drag-handle">
                  <GripVerticalIcon
                    className={cn('h-5 w-5 flex-shrink-0 opacity-40', {
                      'opacity-10': !dragHandleProps,
                    })}
                  />
                </span>

                <FormControl>
                  <Input
                    type="number"
                    min={1}
                    max={stepCount + 1}
                    data-testid="signing-order-input"
                    className={cn(
                      'w-10 text-center',
                      '[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none',
                      {
                        'border-primary/50 bg-primary/5': isGrouped,
                      },
                    )}
                    {...field}
                    onChange={(e) => {
                      field.onChange(e);
                      onSigningOrderChange(signerIndex, e.target.value);
                    }}
                    onBlur={(e) => {
                      field.onBlur();
                      onSigningOrderChange(signerIndex, e.target.value);
                    }}
                    disabled={isFieldDisabled}
                  />
                </FormControl>
                <FormMessage />
              </FormItem>
            )}
          />
        )}

        <FormField
          control={form.control}
          name={`signers.${signerIndex}.email`}
          render={({ field }) => (
            <FormItem
              className={cn('relative w-full', {
                'mb-6': rowErrors && !rowErrors.email,
              })}
            >
              <FormControl>
                <RecipientAutoCompleteInput
                  type="email"
                  placeholder={t`Email`}
                  value={field.value}
                  disabled={isFieldDisabled || isDirectRecipient}
                  options={recipientSuggestions}
                  onSelect={(suggestion) => onAutoCompleteSelect(signerIndex, suggestion)}
                  onSearchQueryChange={(query) => {
                    field.onChange(query);
                    onSearchQueryChange(query);
                  }}
                  loading={isLoadingSuggestions}
                  data-testid="signer-email-input"
                  maxLength={254}
                />
              </FormControl>

              <FormMessage />
            </FormItem>
          )}
        />

        <FormField
          control={form.control}
          name={`signers.${signerIndex}.name`}
          render={({ field }) => (
            <FormItem
              className={cn('w-full', {
                'mb-6': rowErrors && !rowErrors.name,
              })}
            >
              <FormControl>
                <RecipientAutoCompleteInput
                  type="text"
                  placeholder={t`Recipient ${signerIndex + 1}`}
                  {...field}
                  disabled={isFieldDisabled || isDirectRecipient}
                  options={recipientSuggestions}
                  onSelect={(suggestion) => onAutoCompleteSelect(signerIndex, suggestion)}
                  onSearchQueryChange={(query) => {
                    field.onChange(query);
                    onSearchQueryChange(query);
                  }}
                  loading={isLoadingSuggestions}
                  maxLength={255}
                />
              </FormControl>

              <FormMessage />
            </FormItem>
          )}
        />

        <FormField
          control={form.control}
          name={`signers.${signerIndex}.role`}
          render={({ field }) => (
            <FormItem
              className={cn('mt-auto w-fit', {
                'mb-6': rowErrors && !rowErrors.role,
              })}
            >
              <FormControl>
                <RecipientRoleSelect
                  {...field}
                  hideAssistantRole={!editorConfig.recipients?.allowAssistantRole}
                  hideCCerRole={!editorConfig.recipients?.allowCCerRole}
                  hideViewerRole={!editorConfig.recipients?.allowViewerRole}
                  hideApproverRole={!editorConfig.recipients?.allowApproverRole}
                  isAssistantEnabled={isSequential}
                  onValueChange={(value) => {
                    // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
                    onRoleChange(signerIndex, value as RecipientRole);
                  }}
                  disabled={isFieldDisabled}
                />
              </FormControl>

              <FormMessage />
            </FormItem>
          )}
        />

        <Button
          variant="ghost"
          className={cn('mt-auto px-2', {
            'mb-6': rowErrors,
          })}
          data-testid="remove-signer-button"
          disabled={isFieldDisabled || isRemoveDisabled || isDirectRecipient}
          onClick={() => onRemove(signerIndex)}
        >
          <TrashIcon className="h-4 w-4" />
        </Button>
      </div>

      {showAdvancedSettings && organisation.organisationClaim.flags.cfr21 && (
        <FormField
          control={form.control}
          name={`signers.${signerIndex}.actionAuth`}
          render={({ field }) => (
            <FormItem
              className={cn('mt-2 w-full', {
                'mb-6': rowErrors && !rowErrors.actionAuth,
                'pl-6': isSequential,
              })}
            >
              <FormControl>
                <RecipientActionAuthSelect {...field} onValueChange={field.onChange} disabled={isFieldDisabled} />
              </FormControl>

              <FormMessage />
            </FormItem>
          )}
        />
      )}
    </fieldset>
  );
};

Notes vs the original markup: the motion.fieldset becomes a plain fieldset (it carried no animation props); the first-row Email/Name labels move to the list header (Task 14); the CC spacer div is dropped (CC rows render in their own card without an order column).

  • Step 2: Type-check

Run: npx tsc --noEmit (workdir apps/remix) Expected: no errors (component is not yet imported anywhere).

  • Step 3: Commit
git add apps/remix/app/components/general/envelope-editor/recipient-row.tsx
git commit -m "feat: extract envelope editor recipient row component"

Task 13: RecipientStepCard component

Files:

  • Create: apps/remix/app/components/general/envelope-editor/recipient-step-card.tsx

  • Step 1: Create the component

import type { TEditorRecipientsFormSchema } from '@documenso/lib/client-only/hooks/use-editor-recipients';
import type { RecipientStep } from '@documenso/lib/utils/recipient-groups';
import { cn } from '@documenso/ui/lib/utils';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import type { DraggableProvided, DraggableStateSnapshot } from '@hello-pangea/dnd';
import { Draggable, Droppable } from '@hello-pangea/dnd';
import { Trans } from '@lingui/react/macro';
import { GripVerticalIcon, Users2Icon } from 'lucide-react';

import { RecipientRow, type RecipientRowProps } from './recipient-row';

type TEditorSigner = TEditorRecipientsFormSchema['signers'][number];

export type DraggingType = 'STEP' | 'RECIPIENT' | null;

export type RecipientStepCardSharedRowProps = Pick<
  RecipientRowProps,
  | 'stepCount'
  | 'showAdvancedSettings'
  | 'recipientSuggestions'
  | 'isLoadingSuggestions'
  | 'onSigningOrderChange'
  | 'onRoleChange'
  | 'onRemove'
  | 'onAutoCompleteSelect'
  | 'onSearchQueryChange'
>;

export type RecipientStepCardProps = {
  stepIndex: number;
  step: RecipientStep<TEditorSigner>;
  draggableProvided: DraggableProvided;
  draggableSnapshot: DraggableStateSnapshot;
  draggingType: DraggingType;
  isStepLocked: boolean;
  isRemoveDisabled: boolean;
  flatIndexByFormId: Map<string, number>;
  canSignerBeModified: (signer: TEditorSigner) => boolean;
  isSubmitting: boolean;
  onUngroup: (stepIndex: number) => void;
  rowProps: RecipientStepCardSharedRowProps;
};

export const RecipientStepCard = ({
  stepIndex,
  step,
  draggableProvided,
  draggableSnapshot,
  draggingType,
  isStepLocked,
  isRemoveDisabled,
  flatIndexByFormId,
  canSignerBeModified,
  isSubmitting,
  onUngroup,
  rowProps,
}: RecipientStepCardProps) => {
  const isGroup = step.members.length > 1;
  const isCombineTarget = draggingType === 'STEP' && Boolean(draggableSnapshot.combineTargetFor);

  return (
    <div
      ref={draggableProvided.innerRef}
      {...draggableProvided.draggableProps}
      className={cn('py-1', {
        'pointer-events-none': draggableSnapshot.isDragging,
      })}
    >
      <Droppable
        droppableId={`step-members-${stepIndex}`}
        type="RECIPIENT"
        isDropDisabled={draggingType !== 'RECIPIENT'}
      >
        {(droppableProvided, droppableSnapshot) => {
          const isJoinTarget = draggingType === 'RECIPIENT' && droppableSnapshot.isDraggingOver;
          const isHighlighted = isCombineTarget || isJoinTarget;

          return (
            <div
              ref={droppableProvided.innerRef}
              {...droppableProvided.droppableProps}
              data-testid="recipient-step-card"
              className={cn('relative rounded-lg border px-3 pb-1 pt-2', {
                'border-primary/60 bg-primary/5': isGroup,
                'bg-widget-foreground': draggableSnapshot.isDragging,
                'border-primary ring-1 ring-primary': isHighlighted,
              })}
            >
              {isHighlighted && (
                <Badge
                  variant="default"
                  size="small"
                  className="absolute -top-3 right-4 z-10 flex items-center gap-x-1 shadow-sm"
                >
                  <Users2Icon className="h-3 w-3" />
                  <Trans>Release to sign together</Trans>
                </Badge>
              )}

              <div className="flex flex-row items-center gap-x-2">
                <span
                  {...(draggableProvided.dragHandleProps ?? {})}
                  data-testid="step-drag-handle"
                  className={cn({ 'pointer-events-none opacity-30': isStepLocked })}
                >
                  <GripVerticalIcon className="h-4 w-4 opacity-40" />
                </span>

                <Badge variant="neutral" size="small">
                  <Trans>Step {step.order}</Trans>
                </Badge>

                {isGroup && (
                  <>
                    <span className="flex items-center gap-x-1.5 text-xs text-muted-foreground">
                      <Users2Icon className="h-3.5 w-3.5" />
                      <Trans>{step.members.length} signers · any order</Trans>
                    </span>

                    <Button
                      type="button"
                      variant="link"
                      size="sm"
                      data-testid="ungroup-step-button"
                      className="ml-auto h-auto p-0 text-xs"
                      disabled={isStepLocked || isSubmitting}
                      onClick={() => onUngroup(stepIndex)}
                    >
                      <Trans>Ungroup</Trans>
                    </Button>
                  </>
                )}
              </div>

              {step.members.map((member, memberIndex) => {
                const signerIndex = flatIndexByFormId.get(member.formId) ?? -1;
                const canBeModified = canSignerBeModified(member);

                return (
                  <Draggable
                    key={member.formId}
                    draggableId={`recipient-${member.formId}`}
                    index={memberIndex}
                    isDragDisabled={isSubmitting || !canBeModified}
                  >
                    {(memberProvided, memberSnapshot) => (
                      <div
                        ref={memberProvided.innerRef}
                        {...memberProvided.draggableProps}
                        className={cn({
                          'rounded-md bg-widget-foreground': memberSnapshot.isDragging,
                        })}
                      >
                        <RecipientRow
                          signerIndex={signerIndex}
                          signer={member}
                          isSequential={true}
                          isGrouped={isGroup}
                          isInputDisabled={memberSnapshot.isDragging || draggableSnapshot.isDragging}
                          canBeModified={canBeModified}
                          isRemoveDisabled={isRemoveDisabled}
                          dragHandleProps={memberProvided.dragHandleProps}
                          {...rowProps}
                        />
                      </div>
                    )}
                  </Draggable>
                );
              })}

              {droppableProvided.placeholder}
            </div>
          );
        }}
      </Droppable>
    </div>
  );
};
  • Step 2: Type-check

Run: npx tsc --noEmit (workdir apps/remix) Expected: no errors.

  • Step 3: Commit
git add apps/remix/app/components/general/envelope-editor/recipient-step-card.tsx
git commit -m "feat: add envelope editor recipient step card"

Task 14: RecipientStepList component

Files:

  • Create: apps/remix/app/components/general/envelope-editor/recipient-step-list.tsx

Owns: recipient-suggestion search (moved from the form), all mutation handlers (role change, type-to-join order input, remove, ungroup), the nested DnD wiring, gap zones, the flat parallel-mode list, and CC cards.

  • Step 1: Create the component
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import type { TEditorRecipientsFormSchema } from '@documenso/lib/client-only/hooks/use-editor-recipients';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import {
  extractRecipientToNewStep,
  groupRecipientsBySigningOrder,
  mergeSteps,
  moveRecipientToStep,
  normalizeGroupedSigningOrders,
  reorderStep,
  ungroupStep,
} from '@documenso/lib/utils/recipient-groups';
import {
  canEditorRecipientBeModified,
  isAssistantLastSigner,
  isCcRecipient,
} from '@documenso/lib/utils/recipients';
import { trpc } from '@documenso/trpc/react';
import type { RecipientAutoCompleteOption } from '@documenso/ui/components/recipient/recipient-autocomplete-input';
import { cn } from '@documenso/ui/lib/utils';
import { useToast } from '@documenso/ui/primitives/use-toast';
import type { BeforeCapture, DropResult } from '@hello-pangea/dnd';
import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd';
import { Trans, useLingui } from '@lingui/react/macro';
import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
import { useCallback, useMemo, useState } from 'react';
import { useFormContext } from 'react-hook-form';

import { RecipientRow } from './recipient-row';
import { type DraggingType, RecipientStepCard } from './recipient-step-card';

type TEditorSigner = TEditorRecipientsFormSchema['signers'][number];

const RecipientStepGap = ({ gapIndex, draggingType }: { gapIndex: number; draggingType: DraggingType }) => (
  <Droppable droppableId={`gap-${gapIndex}`} type="RECIPIENT" isDropDisabled={draggingType !== 'RECIPIENT'}>
    {(provided, snapshot) => (
      <div
        ref={provided.innerRef}
        {...provided.droppableProps}
        data-testid="recipient-step-gap"
        className={cn(
          'rounded-md transition-all',
          draggingType === 'RECIPIENT' ? 'my-1 min-h-10 border border-dashed' : 'h-2',
          {
            'border-primary bg-primary/10': snapshot.isDraggingOver,
          },
        )}
      >
        <div className="hidden">{provided.placeholder}</div>
      </div>
    )}
  </Droppable>
);

export type RecipientStepListProps = {
  showAdvancedSettings: boolean;
};

export const RecipientStepList = ({ showAdvancedSettings }: RecipientStepListProps) => {
  const { t } = useLingui();
  const { toast } = useToast();

  const { envelope, editorRecipients, isEmbedded } = useCurrentEnvelopeEditor();
  const { form } = editorRecipients;

  const [draggingType, setDraggingType] = useState<DraggingType>(null);
  const [recipientSearchQuery, setRecipientSearchQuery] = useState('');

  const debouncedRecipientSearchQuery = useDebouncedValue(recipientSearchQuery, 500);

  const { data: recipientSuggestionsData, isLoading } = trpc.recipient.suggestions.find.useQuery(
    {
      query: debouncedRecipientSearchQuery,
    },
    {
      enabled: debouncedRecipientSearchQuery.length > 1 && !isEmbedded,
      retry: false,
    },
  );

  const recipientSuggestions = recipientSuggestionsData?.results || [];

  const watchedSigners = form.watch('signers');
  const isSequential = form.watch('signingOrder') === DocumentSigningOrder.SEQUENTIAL;
  const { isSubmitting } = form.formState;

  const { steps, ccRecipients } = useMemo(() => groupRecipientsBySigningOrder(watchedSigners), [watchedSigners]);

  const stepCount = steps.length;
  const isRemoveDisabled = watchedSigners.length === 1;

  const flatIndexByFormId = useMemo(
    () => new Map(watchedSigners.map((signer, index) => [signer.formId, index])),
    [watchedSigners],
  );

  const canSignerBeModified = useCallback(
    (signer: TEditorSigner) => canEditorRecipientBeModified(envelope, signer.id),
    [envelope],
  );

  const applySigners = useCallback(
    (updatedSigners: TEditorSigner[], options: { warnWhenAssistantLast?: boolean } = {}) => {
      const { warnWhenAssistantLast = true } = options;

      form.setValue('signers', updatedSigners, {
        shouldValidate: true,
        shouldDirty: true,
      });

      if (warnWhenAssistantLast && isAssistantLastSigner(updatedSigners)) {
        toast({
          title: t`Warning: Assistant as last signer`,
          description: t`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`,
        });
      }

      void form.trigger('signers');
    },
    [form, t, toast],
  );

  const handleSigningOrderChange = useCallback(
    (signerIndex: number, newOrderString: string) => {
      const trimmedOrderString = newOrderString.trim();

      if (!trimmedOrderString) {
        return;
      }

      const newOrder = Number(trimmedOrderString);

      if (!Number.isInteger(newOrder) || newOrder < 1) {
        return;
      }

      const currentSigners = form.getValues('signers');
      const signer = currentSigners[signerIndex];

      if (!signer || isCcRecipient(signer)) {
        return;
      }

      const { steps: currentSteps } = groupRecipientsBySigningOrder(currentSigners);

      const currentStepIndex = currentSteps.findIndex((step) =>
        step.members.some((member) => member.formId === signer.formId),
      );
      const targetStepIndex = newOrder - 1;

      if (targetStepIndex === currentStepIndex) {
        return;
      }

      // Typing an existing step number joins that step's group; an
      // out-of-bounds number extracts the recipient to a standalone step at
      // the end.
      const updatedSigners =
        targetStepIndex >= currentSteps.length
          ? extractRecipientToNewStep(currentSigners, signer.formId, currentSteps.length, canSignerBeModified)
          : moveRecipientToStep(currentSigners, signer.formId, targetStepIndex, canSignerBeModified);

      applySigners(updatedSigners, { warnWhenAssistantLast: signer.role === RecipientRole.ASSISTANT });
    },
    [form, canSignerBeModified, applySigners],
  );

  const handleRoleChange = useCallback(
    (signerIndex: number, role: RecipientRole) => {
      const currentSigners = form.getValues('signers');
      const signingOrder = form.getValues('signingOrder');

      if (role === RecipientRole.ASSISTANT && signingOrder === DocumentSigningOrder.PARALLEL) {
        form.setValue('signingOrder', DocumentSigningOrder.SEQUENTIAL, {
          shouldValidate: true,
          shouldDirty: true,
        });

        toast({
          title: t`Signing order is enabled.`,
          description: t`You cannot add assistants when signing order is disabled.`,
          variant: 'destructive',
        });

        return;
      }

      const updatedSigners = normalizeGroupedSigningOrders(
        currentSigners.map((signer, index) => ({
          ...signer,
          role: index === signerIndex ? role : signer.role,
        })),
        canSignerBeModified,
      );

      applySigners(updatedSigners, { warnWhenAssistantLast: role === RecipientRole.ASSISTANT });
    },
    [form, toast, t, canSignerBeModified, applySigners],
  );

  const handleRemove = useCallback(
    (signerIndex: number) => {
      const signer = form.getValues('signers')[signerIndex];

      if (!signer) {
        return;
      }

      if (!canSignerBeModified(signer)) {
        toast({
          title: t`Cannot remove signer`,
          description: t`This signer has already signed the document.`,
          variant: 'destructive',
        });

        return;
      }

      const updatedSigners = normalizeGroupedSigningOrders(
        form.getValues('signers').filter((s) => s.formId !== signer.formId),
        canSignerBeModified,
      );

      applySigners(updatedSigners, { warnWhenAssistantLast: false });
    },
    [form, toast, t, canSignerBeModified, applySigners],
  );

  const handleUngroup = useCallback(
    (stepIndex: number) => {
      applySigners(ungroupStep(form.getValues('signers'), stepIndex, canSignerBeModified));
    },
    [form, canSignerBeModified, applySigners],
  );

  const handleAutoCompleteSelect = useCallback(
    (signerIndex: number, suggestion: RecipientAutoCompleteOption) => {
      form.setValue(`signers.${signerIndex}.email`, suggestion.email, {
        shouldValidate: true,
        shouldDirty: true,
      });
      form.setValue(`signers.${signerIndex}.name`, suggestion.name || '', {
        shouldValidate: true,
        shouldDirty: true,
      });
    },
    [form],
  );

  const onBeforeCapture = useCallback((before: BeforeCapture) => {
    setDraggingType(before.draggableId.startsWith('step-') ? 'STEP' : 'RECIPIENT');
  }, []);

  const onDragEnd = useCallback(
    (result: DropResult) => {
      setDraggingType(null);

      const currentSigners = form.getValues('signers');

      if (result.type === 'STEP') {
        if (result.combine) {
          const targetStepIndex = Number(result.combine.draggableId.slice('step-'.length));

          applySigners(mergeSteps(currentSigners, result.source.index, targetStepIndex, canSignerBeModified));

          return;
        }

        if (result.destination) {
          applySigners(reorderStep(currentSigners, result.source.index, result.destination.index, canSignerBeModified));
        }

        return;
      }

      if (result.type === 'RECIPIENT' && result.destination) {
        const formId = result.draggableId.slice('recipient-'.length);
        const { droppableId } = result.destination;

        if (droppableId.startsWith('gap-')) {
          const gapIndex = Number(droppableId.slice('gap-'.length));

          applySigners(extractRecipientToNewStep(currentSigners, formId, gapIndex, canSignerBeModified));

          return;
        }

        if (droppableId.startsWith('step-members-')) {
          const targetStepIndex = Number(droppableId.slice('step-members-'.length));

          applySigners(moveRecipientToStep(currentSigners, formId, targetStepIndex, canSignerBeModified));
        }
      }
    },
    [form, canSignerBeModified, applySigners],
  );

  const sharedRowProps = {
    stepCount,
    showAdvancedSettings,
    recipientSuggestions,
    isLoadingSuggestions: isLoading,
    onSigningOrderChange: handleSigningOrderChange,
    onRoleChange: handleRoleChange,
    onRemove: handleRemove,
    onAutoCompleteSelect: handleAutoCompleteSelect,
    onSearchQueryChange: setRecipientSearchQuery,
  };

  return (
    <div>
      {!showAdvancedSettings && (
        <div className={cn('mb-1 flex flex-row gap-x-2 text-sm', { 'pl-[4.75rem]': isSequential })}>
          <span className="w-full">
            <Trans>Email</Trans>
          </span>
          <span className="w-full">
            <Trans>Name</Trans>
          </span>
          <span className="w-[7.5rem] flex-shrink-0" />
        </div>
      )}

      {!isSequential ? (
        <div className="flex w-full flex-col">
          {watchedSigners.map((signer, index) => (
            <RecipientRow
              key={signer.formId}
              signerIndex={index}
              signer={signer}
              isSequential={false}
              isGrouped={false}
              isInputDisabled={false}
              canBeModified={canSignerBeModified(signer)}
              isRemoveDisabled={isRemoveDisabled}
              dragHandleProps={null}
              {...sharedRowProps}
            />
          ))}
        </div>
      ) : (
        <>
          <DragDropContext onBeforeCapture={onBeforeCapture} onDragEnd={onDragEnd}>
            <Droppable droppableId="recipient-steps" type="STEP" isCombineEnabled>
              {(provided) => (
                <div {...provided.droppableProps} ref={provided.innerRef} className="flex w-full flex-col">
                  {steps.map((step, stepIndex) => {
                    const isStepLocked = step.members.some((member) => !canSignerBeModified(member));

                    return (
                      <div key={`step-fragment-${step.members[0].formId}`}>
                        <RecipientStepGap gapIndex={stepIndex} draggingType={draggingType} />

                        <Draggable
                          draggableId={`step-${stepIndex}`}
                          index={stepIndex}
                          isDragDisabled={isSubmitting || isStepLocked}
                        >
                          {(draggableProvided, draggableSnapshot) => (
                            <RecipientStepCard
                              stepIndex={stepIndex}
                              step={step}
                              draggableProvided={draggableProvided}
                              draggableSnapshot={draggableSnapshot}
                              draggingType={draggingType}
                              isStepLocked={isStepLocked}
                              isRemoveDisabled={isRemoveDisabled}
                              flatIndexByFormId={flatIndexByFormId}
                              canSignerBeModified={canSignerBeModified}
                              isSubmitting={isSubmitting}
                              onUngroup={handleUngroup}
                              rowProps={sharedRowProps}
                            />
                          )}
                        </Draggable>
                      </div>
                    );
                  })}

                  <RecipientStepGap gapIndex={steps.length} draggingType={draggingType} />

                  {provided.placeholder}
                </div>
              )}
            </Droppable>
          </DragDropContext>

          {ccRecipients.map((signer) => (
            <div key={signer.formId} className="my-1 rounded-lg border px-3 py-1">
              <RecipientRow
                signerIndex={flatIndexByFormId.get(signer.formId) ?? -1}
                signer={signer}
                isSequential={true}
                isGrouped={false}
                isInputDisabled={false}
                canBeModified={canSignerBeModified(signer)}
                isRemoveDisabled={isRemoveDisabled}
                dragHandleProps={null}
                {...sharedRowProps}
              />
            </div>
          ))}
        </>
      )}
    </div>
  );
};
  • Step 2: Type-check

Run: npx tsc --noEmit (workdir apps/remix) Expected: no errors.

  • Step 3: Commit
git add apps/remix/app/components/general/envelope-editor/recipient-step-list.tsx
git commit -m "feat: add envelope editor recipient step list with group drag and drop"

Task 15: Rewire envelope-editor-recipient-form.tsx

Files:

  • Modify: apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx

The form keeps: card header (AI detect / Add Myself / Add Signer), the signing-order + dictate checkboxes, the autosave watch-effect (lines 524-588 — unchanged logic), the recipient-limit alert, SigningOrderConfirmation, and the AI dialogs. Everything list-related moves to RecipientStepList.

  • Step 1: Update imports

Remove these imports (now unused here): useDebouncedValue, normalizeRecipientSigningOrders and canRecipientBeModified as utilCanRecipientBeModified (from utils/recipients), trpc, RecipientActionAuthSelect, RecipientAutoCompleteInput/RecipientAutoCompleteOption, RecipientRoleSelect, Input, FormMessage (keep Form, FormControl, FormField, FormItem, FormLabel — all still used by the signing-order/dictate checkboxes), DragDropContext/Draggable/Droppable/DropResult/SensorAPI, motion, GripVerticalIcon/TrashIcon, useFieldArray. Keep useRef (still used by isFirstRender).

Keep: isAssistantLastSigner, isCcRecipient (used by emptySignerIndex/activeRecipientCount replacement below), plural, SendStatus, etc.

Add:

import { groupRecipientsBySigningOrder, normalizeGroupedSigningOrders } from '@documenso/lib/utils/recipient-groups';
import { canEditorRecipientBeModified } from '@documenso/lib/utils/recipients';

import { RecipientStepList } from './recipient-step-list';
  • Step 2: Replace helpers

Delete the $sensorApi ref (line 112) and the useFieldArray block (lines 169-173).

Replace the canRecipientBeModified local function (lines 194-210) with:

  const canRecipientBeModified = (recipientId?: number) => canEditorRecipientBeModified(envelope, recipientId);

Replace normalizeSigningOrders (lines 163-165) with:

  const normalizeSigningOrders = (signers: typeof watchedSigners) => {
    return normalizeGroupedSigningOrders(signers, (signer) => canRecipientBeModified(signer.id));
  };

Replace activeRecipientCount (line 167) with a step count:

  const stepCount = useMemo(() => groupRecipientsBySigningOrder(watchedSigners).steps.length, [watchedSigners]);

In onAddSigner (line 236) and onAddSelfSigner (line 351), replace signingOrder: activeRecipientCount + 1 with signingOrder: stepCount + 1.

Delete these now-moved functions entirely: onDragEnd (lines 371-405), handleRoleChange (lines 407-446), handleSigningOrderChange (lines 448-494), onRemoveSigner (lines 305-329), handleRecipientAutoCompleteSelect (lines 360-369), and the recipient-suggestions state/query (lines 64, 110, 116-126).

Replace all remaining references to the removed signers field-array variable with watchedSigners:

  • isOverRecipientLimit (line 591): watchedSigners.length > recipientCountLimit

  • Add Signer disabled (line 647): watchedSigners.length >= remaining.recipients

  • Step 3: Replace the list markup

Inside <Form {...form}>, replace everything from <DragDropContext (line 797) through its closing </DragDropContext> (line 1077) with:

          <RecipientStepList showAdvancedSettings={showAdvancedSettings} />

Keep the <FormErrorMessage ... signers__root ... /> block after it unchanged.

  • Step 4: Type-check and lint

Run: npx tsc --noEmit (workdir apps/remix), then npm run lint Expected: no errors; lint may flag leftover unused imports — remove any it reports.

  • Step 5: Manual smoke test

Run: npm run dev — open a document editor, verify: sequential off → flat rows; sequential on → step cards with badges; typing a duplicate order groups two recipients (green card, 2 signers · any order, Ungroup); dragging a solo card onto another shows the green ring + "Release to sign together" badge and merges; dragging a member row out to a dashed gap extracts it; Ungroup dissolves; autosave persists (reload keeps the group).

  • Step 6: Commit
git add apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx
git commit -m "feat: render envelope editor recipients as signing step cards"

Task 16: E2E — editor grouping

Files:

  • Create: packages/app-tests/e2e/envelope-editor-v2/envelope-recipient-groups.spec.ts

Follows the envelope-editor-v2 fixture pattern. Grouping is exercised through the type-to-join path (drag correctness is covered by unit tests; DnD in Playwright is flaky).

  • Step 1: Write the spec
import { expect, test } from '@playwright/test';

import { prisma } from '@documenso/prisma';

import {
  type TEnvelopeEditorSurface,
  clickAddSignerButton,
  getSigningOrderInputs,
  openDocumentEnvelopeEditor,
  openTemplateEnvelopeEditor,
  setRecipientEmail,
  setRecipientName,
  setSigningOrderValue,
  toggleSigningOrder,
} from '../fixtures/envelope-editor';

const expectRecipientOrders = async (surface: TEnvelopeEditorSurface, expected: Array<[string, number]>) => {
  await expect
    .poll(
      async () => {
        const recipients = await prisma.recipient.findMany({
          where: { envelopeId: surface.envelopeId },
        });

        return recipients.map((r) => [r.email, r.signingOrder] as const).sort((a, b) => a[0].localeCompare(b[0]));
      },
      { timeout: 15_000 },
    )
    .toEqual([...expected].sort((a, b) => a[0].localeCompare(b[0])));
};

const runGroupingFlow = async (surface: TEnvelopeEditorSurface) => {
  const { root } = surface;

  await setRecipientEmail(root, 0, 'alice@example.com');
  await setRecipientName(root, 0, 'Alice');

  await clickAddSignerButton(root);
  await setRecipientEmail(root, 1, 'bob@example.com');

  await clickAddSignerButton(root);
  await setRecipientEmail(root, 2, 'carol@example.com');

  await toggleSigningOrder(root, true);

  // Three standalone steps.
  await expect(root.getByText('Step 1', { exact: true })).toBeVisible();
  await expect(root.getByText('Step 3', { exact: true })).toBeVisible();

  // Type-to-join: carol (step 3) joins bob (step 2).
  await setSigningOrderValue(root, 2, '2');

  await expect(root.getByText('2 signers · any order')).toBeVisible();
  await expect(root.getByTestId('ungroup-step-button')).toBeVisible();
  await expect(root.getByText('Step 3', { exact: true })).not.toBeVisible();

  const orderInputs = getSigningOrderInputs(root);
  await expect(orderInputs.nth(1)).toHaveValue('2');
  await expect(orderInputs.nth(2)).toHaveValue('2');

  await expectRecipientOrders(surface, [
    ['alice@example.com', 1],
    ['bob@example.com', 2],
    ['carol@example.com', 2],
  ]);

  // Groups survive a reload (grouped normalization on load).
  await root.reload();
  await expect(root.getByText('2 signers · any order')).toBeVisible();

  // Ungroup dissolves back into sequential steps.
  await root.getByTestId('ungroup-step-button').click();

  await expect(root.getByText('2 signers · any order')).not.toBeVisible();
  await expect(root.getByText('Step 3', { exact: true })).toBeVisible();

  await expectRecipientOrders(surface, [
    ['alice@example.com', 1],
    ['bob@example.com', 2],
    ['carol@example.com', 3],
  ]);

  // Out-of-bounds extraction: bob (step 2) types 4 (> 3 steps) and becomes the
  // last standalone step.
  await setSigningOrderValue(root, 1, '4');

  await expectRecipientOrders(surface, [
    ['alice@example.com', 1],
    ['bob@example.com', 3],
    ['carol@example.com', 2],
  ]);
};

test.describe('document editor', () => {
  test('documents: group recipients via signing order input and ungroup', async ({ page }) => {
    const surface = await openDocumentEnvelopeEditor(page);

    await runGroupingFlow(surface);
  });
});

test.describe('template editor', () => {
  test('templates: group recipients via signing order input and ungroup', async ({ page }) => {
    const surface = await openTemplateEnvelopeEditor(page);

    await runGroupingFlow(surface);
  });
});
  • Step 2: Run the spec

Run: npm run test:dev -w @documenso/app-tests -- e2e/envelope-editor-v2/envelope-recipient-groups.spec.ts Expected: 2 tests pass. If the Step N badge text or the group header copy differs from the implementation, fix the spec assertions to match the rendered strings (they are defined in Task 13).

  • Step 3: Commit
git add packages/app-tests/e2e/envelope-editor-v2/envelope-recipient-groups.spec.ts
git commit -m "test: add envelope editor recipient grouping e2e"

Task 17: E2E — group signing flow

Files:

  • Create: packages/app-tests/e2e/recipient/signing-groups.spec.ts

Seeds a pending document with orders [1, 2, 2, 3] and walks the full flow: waiting-page gating, any-order signing within the group, gate release, completion.

  • Step 1: Write the spec
import { expect, test } from '@playwright/test';
import type { Page } from '@playwright/test';
import { DocumentSigningOrder, DocumentStatus, FieldType } from '@prisma/client';

import { prisma } from '@documenso/prisma';
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';

import { signSignaturePad } from '../fixtures/signature';

type SeededRecipient = Awaited<ReturnType<typeof seedPendingDocumentWithFullFields>>['recipients'][number];

const completeSigning = async (page: Page, recipient: SeededRecipient) => {
  const signUrl = `/sign/${recipient.token}`;

  await page.goto(signUrl);
  await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();

  await signSignaturePad(page);

  for (const field of recipient.fields) {
    await page.locator(`#field-${field.id}`).getByRole('button').click();

    if (field.type === FieldType.TEXT) {
      await page.locator('#custom-text').fill('TEXT');
      await page.getByRole('button', { name: 'Save' }).click();
    }

    await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
  }

  await page.getByRole('button', { name: 'Complete' }).click();
  await page.getByRole('button', { name: 'Sign' }).click();
  await page.waitForURL(`${signUrl}/complete`);
};

const expectWaiting = async (page: Page, token: string) => {
  await page.goto(`/sign/${token}`);
  await page.waitForURL(`/sign/${token}/waiting`);
};

test('[SIGNING_GROUPS]: group members sign in any order and gate the next step', async ({ page }) => {
  const { user, team } = await seedUser();
  const { user: signer1 } = await seedUser();
  const { user: signer2a } = await seedUser();
  const { user: signer2b } = await seedUser();
  const { user: signer3 } = await seedUser();

  const { recipients, document } = await seedPendingDocumentWithFullFields({
    owner: user,
    teamId: team.id,
    recipients: [signer1, signer2a, signer2b, signer3],
    recipientsCreateOptions: [
      { signingOrder: 1 },
      { signingOrder: 2 },
      { signingOrder: 2 },
      { signingOrder: 3 },
    ],
    updateDocumentOptions: {
      documentMeta: {
        upsert: {
          create: { signingOrder: DocumentSigningOrder.SEQUENTIAL },
          update: { signingOrder: DocumentSigningOrder.SEQUENTIAL },
        },
      },
    },
  });

  const [recipient1, recipient2a, recipient2b, recipient3] = recipients;

  // While step 1 is pending, both group members and step 3 are blocked.
  await expectWaiting(page, recipient2a.token);
  await expectWaiting(page, recipient2b.token);
  await expectWaiting(page, recipient3.token);

  await completeSigning(page, recipient1);

  // Step 3 is still blocked; the group is now active.
  await expectWaiting(page, recipient3.token);

  // Sign with the SECOND group member first to prove any-order signing.
  await completeSigning(page, recipient2b);

  // One group member remains — step 3 stays blocked.
  await expectWaiting(page, recipient3.token);

  await completeSigning(page, recipient2a);

  // The whole group is done — step 3 unlocks and completes the document.
  await completeSigning(page, recipient3);

  await expect
    .poll(async () => {
      const envelope = await prisma.envelope.findUniqueOrThrow({
        where: { id: document.id },
      });

      return envelope.status;
    })
    .toBe(DocumentStatus.COMPLETED);
});
  • Step 2: Run the spec

Run: npm run test:dev -w @documenso/app-tests -- e2e/recipient/signing-groups.spec.ts Expected: 1 test passes. If the post-Complete confirmation dialog has no Sign button in this configuration (no dictation), drop the second click — mirror whatever e2e/document-auth/next-recipient-dictation.spec.ts's disabled-dictation test does.

  • Step 3: Commit
git add packages/app-tests/e2e/recipient/signing-groups.spec.ts
git commit -m "test: add signing group flow e2e"

Task 18: Final verification

  • Step 1: Unit tests

Run: npm run test -w @documenso/lib Expected: all pass.

  • Step 2: Type checks

Run: npx tsc --noEmit in packages/lib, packages/trpc, and apps/remix (three separate runs, using each as workdir). Expected: no errors.

  • Step 3: Lint

Run: npm run lint Expected: clean (run npm run lint:fix for autofixable issues).

  • Step 4: Full E2E for touched areas

Run: npm run test:dev -w @documenso/app-tests -- e2e/envelope-editor-v2/ e2e/recipient/signing-groups.spec.ts e2e/document-auth/next-recipient-dictation.spec.ts Expected: all pass — the dictation spec proves single-file documents still dictate correctly after the group changes.

  • Step 5: Commit any remaining fixes
git status
git add -A && git commit -m "fix: address verification findings for signing groups"

Only commit if there are changes; use a message describing the actual fix.


Coverage map (spec → tasks)

Spec section Tasks
§1 Data model & ordering 1, 3
§2 Shared pure utilities 1, 2, 3, 4
§3 Editor UI structure & visuals 12, 13, 14, 15
§4 Editor interactions (drag, type-to-join, ungroup, locked) 2, 14, 15
§5.1 Turn checks 3, 5
§5.2 Initial send 6
§5.3 Completion advance 7, 8 (direct template)
§5.4 Dictation gating 3, 7, 8, 10
§5.5 Assistants 4, 9
§5.6 CSC blocking 11
§6 Validation & compatibility (grouped load, V1 untouched) 11
§7 Testing 1-4 (unit), 16-17 (E2E), 18