diff --git a/packages/lib/utils/recipient-groups.test.ts b/packages/lib/utils/recipient-groups.test.ts new file mode 100644 index 000000000..6768ed902 --- /dev/null +++ b/packages/lib/utils/recipient-groups.test.ts @@ -0,0 +1,133 @@ +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], + ]); + }); +}); diff --git a/packages/lib/utils/recipient-groups.ts b/packages/lib/utils/recipient-groups.ts new file mode 100644 index 000000000..b0e30a545 --- /dev/null +++ b/packages/lib/utils/recipient-groups.ts @@ -0,0 +1,109 @@ +import type { Recipient } from '@prisma/client'; + +import { isCcRecipient } from './recipients'; + +/** + * A recipient "step" is the set of non-CC recipients sharing a signing order. + * A step with 2 or more members is a "signing group": members may act in any + * order among themselves, and the next step only unlocks once every member of + * the group has completed their action. + */ + +type GroupableRecipient = Pick & { + signingOrder?: number | null; +}; + +export type RecipientStep = { + /** + * The signing order shared by all members of the step. + */ + order: number; + members: T[]; +}; + +const UNORDERED = Number.MAX_SAFE_INTEGER; + +const effectiveOrder = (recipient: { signingOrder?: number | null }) => recipient.signingOrder ?? UNORDERED; + +/** + * Derives the ordered list of steps from a list of recipients. + * + * - Non-CC recipients sharing a signing order form one step. + * - Recipients without a signing order share a single tail step. + * - CC recipients are returned separately and never belong to a step. + */ +export const groupRecipientsBySigningOrder = (recipients: T[]) => { + const ccRecipients = recipients.filter((recipient) => isCcRecipient(recipient)); + const nonCcRecipients = recipients.filter((recipient) => !isCcRecipient(recipient)); + + const membersByOrder = new Map(); + + for (const recipient of nonCcRecipients) { + const order = effectiveOrder(recipient); + const members = membersByOrder.get(order) ?? []; + + members.push(recipient); + membersByOrder.set(order, members); + } + + const steps: RecipientStep[] = [...membersByOrder.entries()] + .sort(([orderA], [orderB]) => orderA - orderB) + .map(([order, members]) => ({ order, members })); + + return { steps, ccRecipients }; +}; + +/** + * Dense-renumbers steps to 1..K while preserving groups (duplicate orders). + * + * Steps containing a locked recipient (per `canUpdateRecipient`) keep the + * locked recipient's persisted order, and editable steps never collide into a + * locked step's number. + * + * CC recipients get an undefined signing order and move to the tail. The + * returned array is re-ordered by step sequence. + */ +export const normalizeGroupedSigningOrders = ( + recipients: T[], + canUpdateRecipient: (recipient: T) => boolean = () => true, +): Array => { + const { steps, ccRecipients } = groupRecipientsBySigningOrder(recipients); + + const lockedOrderByStepIndex = new Map(); + + steps.forEach((step, index) => { + const lockedMember = step.members.find((member) => !canUpdateRecipient(member)); + + if (lockedMember && typeof lockedMember.signingOrder === 'number') { + lockedOrderByStepIndex.set(index, lockedMember.signingOrder); + } + }); + + const reservedOrders = new Set(lockedOrderByStepIndex.values()); + const normalizedSteps: RecipientStep[] = []; + + let nextOrder = 1; + + steps.forEach((step, index) => { + const lockedOrder = lockedOrderByStepIndex.get(index); + + if (lockedOrder !== undefined) { + normalizedSteps.push({ order: lockedOrder, members: step.members }); + nextOrder = Math.max(nextOrder, lockedOrder + 1); + + return; + } + + while (reservedOrders.has(nextOrder)) { + nextOrder += 1; + } + + normalizedSteps.push({ order: nextOrder, members: step.members }); + nextOrder += 1; + }); + + return [ + ...normalizedSteps.flatMap((step) => step.members.map((member) => ({ ...member, signingOrder: step.order }))), + ...ccRecipients.map((recipient) => ({ ...recipient, signingOrder: undefined })), + ]; +};