From 9d0e61716e1bcb1c590050b35aa5258315b74e8a Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Tue, 4 Aug 2026 17:22:56 +1000 Subject: [PATCH] feat: add signing group editor operations --- packages/lib/utils/recipient-groups.test.ts | 198 +++++++++++++++++++- packages/lib/utils/recipient-groups.ts | 184 ++++++++++++++++++ 2 files changed, 381 insertions(+), 1 deletion(-) diff --git a/packages/lib/utils/recipient-groups.test.ts b/packages/lib/utils/recipient-groups.test.ts index 6768ed902..dc3580761 100644 --- a/packages/lib/utils/recipient-groups.test.ts +++ b/packages/lib/utils/recipient-groups.test.ts @@ -1,7 +1,15 @@ import { RecipientRole } from '@prisma/client'; import { describe, expect, it } from 'vitest'; -import { groupRecipientsBySigningOrder, normalizeGroupedSigningOrders } from './recipient-groups'; +import { + extractRecipientToNewStep, + groupRecipientsBySigningOrder, + mergeSteps, + moveRecipientToStep, + normalizeGroupedSigningOrders, + reorderStep, + ungroupStep, +} from './recipient-groups'; describe('groupRecipientsBySigningOrder', () => { it('groups non-CC recipients sharing a signing order into steps', () => { @@ -131,3 +139,191 @@ describe('normalizeGroupedSigningOrders', () => { ]); }); }); + +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 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)); + }); +}); diff --git a/packages/lib/utils/recipient-groups.ts b/packages/lib/utils/recipient-groups.ts index b0e30a545..ea9526657 100644 --- a/packages/lib/utils/recipient-groups.ts +++ b/packages/lib/utils/recipient-groups.ts @@ -107,3 +107,187 @@ export const normalizeGroupedSigningOrders = ( ...ccRecipients.map((recipient) => ({ ...recipient, signingOrder: undefined })), ]; }; + +type EditorRecipient = GroupableRecipient & { formId: string }; + +/** + * Merges all members of the source step into the target step. + */ +export const mergeSteps = ( + recipients: T[], + sourceStepIndex: number, + targetStepIndex: number, + canUpdateRecipient?: (recipient: T) => boolean, +): Array => { + const { steps } = groupRecipientsBySigningOrder(recipients); + + const sourceStep = steps[sourceStepIndex]; + const targetStep = steps[targetStepIndex]; + + if (!sourceStep || !targetStep || sourceStepIndex === targetStepIndex) { + return recipients; + } + + const sourceFormIds = new Set(sourceStep.members.map((member) => member.formId)); + + // Source members join after the target step's existing members. + const remaining = recipients.filter((recipient) => !sourceFormIds.has(recipient.formId)); + const lastMemberFormId = targetStep.members[targetStep.members.length - 1].formId; + const insertAfterIndex = remaining.findIndex((recipient) => recipient.formId === lastMemberFormId); + + const movedMembers = sourceStep.members.map((member) => ({ ...member, signingOrder: targetStep.order })); + + const updated = [ + ...remaining.slice(0, insertAfterIndex + 1), + ...movedMembers, + ...remaining.slice(insertAfterIndex + 1), + ]; + + return normalizeGroupedSigningOrders(updated, canUpdateRecipient); +}; + +/** + * Moves a single recipient into the target step (joins the group). + */ +export const moveRecipientToStep = ( + recipients: T[], + formId: string, + targetStepIndex: number, + canUpdateRecipient?: (recipient: T) => boolean, +): Array => { + const { steps } = groupRecipientsBySigningOrder(recipients); + + const targetStep = steps[targetStepIndex]; + const mover = recipients.find((recipient) => recipient.formId === formId); + + if (!targetStep || !mover || isCcRecipient(mover)) { + return recipients; + } + + if (targetStep.members.some((member) => member.formId === formId)) { + return recipients; + } + + const remaining = recipients.filter((recipient) => recipient.formId !== formId); + const lastMemberFormId = targetStep.members[targetStep.members.length - 1].formId; + const insertAfterIndex = remaining.findIndex((recipient) => recipient.formId === lastMemberFormId); + + const updated = [ + ...remaining.slice(0, insertAfterIndex + 1), + { ...mover, signingOrder: targetStep.order }, + ...remaining.slice(insertAfterIndex + 1), + ]; + + return normalizeGroupedSigningOrders(updated, canUpdateRecipient); +}; + +/** + * Extracts a recipient into its own standalone step at the given gap position + * (gap N sits before step N; an out-of-bounds gap appends to the end). + */ +export const extractRecipientToNewStep = ( + recipients: T[], + formId: string, + insertStepIndex: number, + canUpdateRecipient?: (recipient: T) => boolean, +): Array => { + const { steps } = groupRecipientsBySigningOrder(recipients); + + const mover = recipients.find((recipient) => recipient.formId === formId); + + if (!mover || isCcRecipient(mover)) { + return recipients; + } + + const currentStepIndex = steps.findIndex((step) => step.members.some((member) => member.formId === formId)); + const isSoloStep = currentStepIndex !== -1 && steps[currentStepIndex].members.length === 1; + + // Dropping a solo step into the gap directly above or below itself is a no-op. + if (isSoloStep && (insertStepIndex === currentStepIndex || insertStepIndex === currentStepIndex + 1)) { + return recipients; + } + + const insertOrder = + insertStepIndex >= steps.length ? (steps[steps.length - 1]?.order ?? 0) + 1 : steps[insertStepIndex].order - 0.5; + + const updated = recipients.map((recipient) => + recipient.formId === formId ? { ...recipient, signingOrder: insertOrder } : recipient, + ); + + return normalizeGroupedSigningOrders(updated, canUpdateRecipient); +}; + +/** + * Moves a whole step (group) to a new position in the step sequence. + * + * Locked steps keep their members' persisted orders untouched (the sequence + * flows around them), and editable steps never collide onto a locked anchor — + * that would accidentally merge them during re-derivation. + */ +export const reorderStep = ( + recipients: T[], + fromStepIndex: number, + toStepIndex: number, + canUpdateRecipient: (recipient: T) => boolean = () => true, +): Array => { + const { steps, ccRecipients } = groupRecipientsBySigningOrder(recipients); + + if (!steps[fromStepIndex] || fromStepIndex === toStepIndex) { + return recipients; + } + + const reorderedSteps = [...steps]; + const [movedStep] = reorderedSteps.splice(fromStepIndex, 1); + + reorderedSteps.splice(Math.min(toStepIndex, reorderedSteps.length), 0, movedStep); + + const isStepLocked = (step: RecipientStep) => step.members.some((member) => !canUpdateRecipient(member)); + + const lockedAnchors = new Set(reorderedSteps.filter((step) => isStepLocked(step)).map((step) => step.order)); + + const updated = [ + ...reorderedSteps.flatMap((step, index) => { + if (isStepLocked(step)) { + return step.members; + } + + const tempOrder = lockedAnchors.has(index + 1) ? index + 1.5 : index + 1; + + return step.members.map((member) => ({ ...member, signingOrder: tempOrder })); + }), + ...ccRecipients, + ]; + + return normalizeGroupedSigningOrders(updated, canUpdateRecipient); +}; + +/** + * Dissolves a group into consecutive standalone steps preserving relative order. + */ +export const ungroupStep = ( + recipients: T[], + stepIndex: number, + canUpdateRecipient?: (recipient: T) => boolean, +): Array => { + const { steps } = groupRecipientsBySigningOrder(recipients); + + const step = steps[stepIndex]; + + if (!step || step.members.length < 2) { + return recipients; + } + + const offsetByFormId = new Map(step.members.map((member, index) => [member.formId, index])); + + const updated = recipients.map((recipient) => { + const offset = offsetByFormId.get(recipient.formId); + + if (offset === undefined) { + return recipient; + } + + return { ...recipient, signingOrder: step.order + offset / (step.members.length + 1) }; + }); + + return normalizeGroupedSigningOrders(updated, canUpdateRecipient); +};