diff --git a/packages/lib/utils/recipient-groups.test.ts b/packages/lib/utils/recipient-groups.test.ts index dc3580761..01e8870e3 100644 --- a/packages/lib/utils/recipient-groups.test.ts +++ b/packages/lib/utils/recipient-groups.test.ts @@ -1,9 +1,12 @@ -import { RecipientRole } from '@prisma/client'; +import { RecipientRole, SigningStatus } from '@prisma/client'; import { describe, expect, it } from 'vitest'; import { extractRecipientToNewStep, + filterRecipientsInFirstSigningGroup, + getDictatableNextRecipient, groupRecipientsBySigningOrder, + isRecipientTurnBySigningOrder, mergeSteps, moveRecipientToStep, normalizeGroupedSigningOrders, @@ -327,3 +330,137 @@ describe('ungroupStep', () => { expect(ordersOf(ungroupStep(signers, 0))).toEqual(ordersOf(signers)); }); }); + +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(); + }); +}); diff --git a/packages/lib/utils/recipient-groups.ts b/packages/lib/utils/recipient-groups.ts index ea9526657..16d45a4dd 100644 --- a/packages/lib/utils/recipient-groups.ts +++ b/packages/lib/utils/recipient-groups.ts @@ -1,4 +1,5 @@ import type { Recipient } from '@prisma/client'; +import { SigningStatus } from '@prisma/client'; import { isCcRecipient } from './recipients'; @@ -261,6 +262,96 @@ export const reorderStep = ( return normalizeGroupedSigningOrders(updated, canUpdateRecipient); }; +type SignableRecipient = Pick & { + signingOrder?: number | null; +}; + +/** + * Whether it is the recipient's turn to act under SEQUENTIAL signing. + * + * A recipient may act iff no non-CC recipient with a strictly lower signing + * order is still unsigned (rejected counts as unsigned/blocking). Recipients + * sharing a signing order never block each other. + * + * Callers are responsible for checking the document is in SEQUENTIAL mode. + */ +export const isRecipientTurnBySigningOrder = ( + recipients: T[], + currentRecipient: { signingOrder?: number | null }, +): boolean => { + const currentOrder = effectiveOrder(currentRecipient); + + return !recipients.some( + (recipient) => + !isCcRecipient(recipient) && + recipient.signingStatus !== SigningStatus.SIGNED && + effectiveOrder(recipient) < currentOrder, + ); +}; + +/** + * Returns every pending recipient sharing the lowest pending signing order — + * the "active group". Callers pass an already-filtered pending list. + */ +export const filterRecipientsInFirstSigningGroup = ( + pendingRecipients: T[], +): T[] => { + if (pendingRecipients.length === 0) { + return []; + } + + const minOrder = Math.min(...pendingRecipients.map((recipient) => effectiveOrder(recipient))); + + return pendingRecipients.filter((recipient) => effectiveOrder(recipient) === minOrder); +}; + +/** + * The single recipient that the current recipient may dictate (rename) on + * completion, or null when dictation does not apply: + * + * - the current recipient must be the last unsigned member of their step, and + * - the next step must contain exactly one recipient. + */ +export const getDictatableNextRecipient = >({ + recipients, + currentRecipientId, +}: { + recipients: T[]; + currentRecipientId: number; +}): T | null => { + const currentRecipient = recipients.find((recipient) => recipient.id === currentRecipientId); + + if (!currentRecipient || isCcRecipient(currentRecipient)) { + return null; + } + + const currentOrder = effectiveOrder(currentRecipient); + + const hasUnsignedPeers = recipients.some( + (recipient) => + recipient.id !== currentRecipientId && + !isCcRecipient(recipient) && + effectiveOrder(recipient) === currentOrder && + recipient.signingStatus !== SigningStatus.SIGNED, + ); + + if (hasUnsignedPeers) { + return null; + } + + const laterRecipients = recipients.filter( + (recipient) => !isCcRecipient(recipient) && effectiveOrder(recipient) > currentOrder, + ); + + const nextStep = filterRecipientsInFirstSigningGroup(laterRecipients); + + if (nextStep.length !== 1) { + return null; + } + + return nextStep[0]; +}; + /** * Dissolves a group into consecutive standalone steps preserving relative order. */