feat: add group-aware turn and dictation helpers

This commit is contained in:
David Nguyen
2026-08-04 17:24:53 +10:00
parent 9d0e61716e
commit 74c1752853
2 changed files with 229 additions and 1 deletions
+138 -1
View File
@@ -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();
});
});
+91
View File
@@ -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 = <T extends EditorRecipient>(
return normalizeGroupedSigningOrders(updated, canUpdateRecipient);
};
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];
};
/**
* Dissolves a group into consecutive standalone steps preserving relative order.
*/