mirror of
https://github.com/documenso/documenso.git
synced 2026-08-27 00:32:29 +10:00
fix: wip
This commit is contained in:
@@ -0,0 +1,692 @@
|
||||
import { RecipientRole, SigningStatus } from '@prisma/client';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
extractRecipientToNewStep,
|
||||
filterRecipientsInFirstSigningGroup,
|
||||
flattenRecipientGroups,
|
||||
getNextDictatableRecipient,
|
||||
groupRecipientsBySigningOrder,
|
||||
isRecipientTurnBySigningOrder,
|
||||
mergeSteps,
|
||||
moveRecipientToStep,
|
||||
normalizeGroupedSigningOrders,
|
||||
reorderStep,
|
||||
ungroupStep,
|
||||
} 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],
|
||||
]);
|
||||
});
|
||||
|
||||
// Everything up to and including the last locked step is locked, so those
|
||||
// orders are persisted values and must survive untouched — even the sparse
|
||||
// ones an API caller may have created.
|
||||
it('freezes every step up to and including the last locked step', () => {
|
||||
const recipients = [
|
||||
{ formId: 'a', role: RecipientRole.SIGNER, signingOrder: 3 },
|
||||
{ formId: 'locked', role: RecipientRole.SIGNER, signingOrder: 5 },
|
||||
{ formId: 'b', role: RecipientRole.SIGNER, signingOrder: 9 },
|
||||
];
|
||||
|
||||
const normalized = normalizeGroupedSigningOrders(recipients, (r) => r.formId !== 'locked');
|
||||
|
||||
expect(normalized.map((r) => [r.formId, r.signingOrder])).toEqual([
|
||||
['a', 3],
|
||||
['locked', 5],
|
||||
['b', 6],
|
||||
]);
|
||||
});
|
||||
|
||||
it('renumbers the unlocked tail densely from the highest locked order', () => {
|
||||
const recipients = [
|
||||
{ formId: 'locked', role: RecipientRole.SIGNER, signingOrder: 4 },
|
||||
{ formId: 'a', role: RecipientRole.SIGNER, signingOrder: 20 },
|
||||
{ formId: 'b', role: RecipientRole.SIGNER, signingOrder: 31 },
|
||||
];
|
||||
|
||||
const normalized = normalizeGroupedSigningOrders(recipients, (r) => r.formId !== 'locked');
|
||||
|
||||
expect(normalized.map((r) => [r.formId, r.signingOrder])).toEqual([
|
||||
['locked', 4],
|
||||
['a', 5],
|
||||
['b', 6],
|
||||
]);
|
||||
});
|
||||
|
||||
it('leaves a locked recipient without a persisted order alone', () => {
|
||||
const recipients: Array<{ formId: string; role: RecipientRole; signingOrder: number | null }> = [
|
||||
{ formId: 'locked', role: RecipientRole.SIGNER, signingOrder: null },
|
||||
{ formId: 'a', role: RecipientRole.SIGNER, signingOrder: null },
|
||||
];
|
||||
|
||||
// Both share the null tail step, so the whole step is locked.
|
||||
const normalized = normalizeGroupedSigningOrders(recipients, (r) => r.formId !== 'locked');
|
||||
|
||||
expect(normalized.map((r) => [r.formId, r.signingOrder])).toEqual([
|
||||
['locked', undefined],
|
||||
['a', undefined],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
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));
|
||||
});
|
||||
|
||||
// Splitting a locked step would rewrite persisted orders, so it is refused
|
||||
// rather than attempted.
|
||||
it('is a no-op on a locked step', () => {
|
||||
const signers = [
|
||||
{ formId: 'locked', role: RecipientRole.SIGNER, signingOrder: 1 },
|
||||
{ formId: 'peer', role: RecipientRole.SIGNER, signingOrder: 1 },
|
||||
{ formId: 'c', role: RecipientRole.SIGNER, signingOrder: 2 },
|
||||
];
|
||||
|
||||
const result = ungroupStep(signers, 0, (r) => r.formId !== 'locked');
|
||||
|
||||
expect(ordersOf(result)).toEqual([
|
||||
['locked', 1],
|
||||
['peer', 1],
|
||||
['c', 2],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('locked step guards', () => {
|
||||
const withLockedHead = () => [
|
||||
{ formId: 'locked', role: RecipientRole.SIGNER, signingOrder: 1 },
|
||||
{ formId: 'b', role: RecipientRole.SIGNER, signingOrder: 2 },
|
||||
{ formId: 'c', role: RecipientRole.SIGNER, signingOrder: 3 },
|
||||
];
|
||||
|
||||
const canUpdate = (r: { formId: string }) => r.formId !== 'locked';
|
||||
|
||||
it('reorderStep refuses to move a step into the locked region', () => {
|
||||
const signers = withLockedHead();
|
||||
|
||||
expect(ordersOf(reorderStep(signers, 2, 0, canUpdate))).toEqual(ordersOf(signers));
|
||||
});
|
||||
|
||||
it('reorderStep refuses to move a locked step', () => {
|
||||
const signers = withLockedHead();
|
||||
|
||||
expect(ordersOf(reorderStep(signers, 0, 2, canUpdate))).toEqual(ordersOf(signers));
|
||||
});
|
||||
|
||||
it('extractRecipientToNewStep refuses to insert into the locked region', () => {
|
||||
const signers = withLockedHead();
|
||||
|
||||
expect(ordersOf(extractRecipientToNewStep(signers, 'c', 0, canUpdate))).toEqual(ordersOf(signers));
|
||||
});
|
||||
|
||||
it('mergeSteps refuses to merge into a locked step', () => {
|
||||
const signers = withLockedHead();
|
||||
|
||||
expect(ordersOf(mergeSteps(signers, 2, 0, canUpdate))).toEqual(ordersOf(signers));
|
||||
});
|
||||
|
||||
it('moveRecipientToStep refuses to move a recipient into a locked step', () => {
|
||||
const signers = withLockedHead();
|
||||
|
||||
expect(ordersOf(moveRecipientToStep(signers, 'c', 0, canUpdate))).toEqual(ordersOf(signers));
|
||||
});
|
||||
|
||||
it('still allows reordering entirely within the unlocked tail', () => {
|
||||
const signers = withLockedHead();
|
||||
|
||||
expect(ordersOf(reorderStep(signers, 1, 2, canUpdate))).toEqual([
|
||||
['locked', 1],
|
||||
['c', 2],
|
||||
['b', 3],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('flattenRecipientGroups', () => {
|
||||
const recipient = (id: number, signingOrder: number | null, role: RecipientRole = RecipientRole.SIGNER) => ({
|
||||
id,
|
||||
signingOrder,
|
||||
role,
|
||||
});
|
||||
|
||||
it('returns no changes when every signing recipient already has their own step', () => {
|
||||
expect(flattenRecipientGroups([recipient(1, 1), recipient(2, 2)])).toEqual([]);
|
||||
});
|
||||
|
||||
// Sparse but distinct orders are valid; only a shared step needs repairing.
|
||||
it('leaves a valid but sparse sequence alone', () => {
|
||||
expect(flattenRecipientGroups([recipient(1, 1), recipient(2, 5)])).toEqual([]);
|
||||
});
|
||||
|
||||
it('splits a shared step while preserving relative order', () => {
|
||||
const changes = flattenRecipientGroups([recipient(1, 1), recipient(2, 2), recipient(3, 2)]);
|
||||
|
||||
// Only the second member of the shared step has to move.
|
||||
expect(changes).toEqual([{ id: 3, signingOrder: 3 }]);
|
||||
});
|
||||
|
||||
it('gives every unordered recipient a distinct step', () => {
|
||||
const changes = flattenRecipientGroups([recipient(1, null), recipient(2, null)]);
|
||||
|
||||
expect(changes).toEqual([
|
||||
{ id: 1, signingOrder: 1 },
|
||||
{ id: 2, signingOrder: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores CC recipients', () => {
|
||||
expect(flattenRecipientGroups([recipient(1, 1), recipient(2, 1, RecipientRole.CC)])).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not mutate the input array order', () => {
|
||||
const recipients = [recipient(3, 2), recipient(1, 1), recipient(2, 2)];
|
||||
|
||||
flattenRecipientGroups(recipients);
|
||||
|
||||
expect(recipients.map((r) => r.id)).toEqual([3, 1, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
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', () => {
|
||||
const candidate = (
|
||||
id: number,
|
||||
signingOrder: number | null,
|
||||
signingStatus: SigningStatus = SigningStatus.NOT_SIGNED,
|
||||
role: RecipientRole = RecipientRole.SIGNER,
|
||||
) => ({ id, signingOrder, signingStatus, role });
|
||||
|
||||
it('returns every pending recipient sharing the lowest order', () => {
|
||||
const recipients = [candidate(3, 2), candidate(4, 2), candidate(5, 3)];
|
||||
|
||||
expect(filterRecipientsInFirstSigningGroup(recipients).map((r) => r.id)).toEqual([3, 4]);
|
||||
});
|
||||
|
||||
it('returns an empty array for no pending recipients', () => {
|
||||
expect(filterRecipientsInFirstSigningGroup([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('excludes recipients that have already signed', () => {
|
||||
const recipients = [candidate(1, 1, SigningStatus.SIGNED), candidate(2, 2)];
|
||||
|
||||
expect(filterRecipientsInFirstSigningGroup(recipients).map((r) => r.id)).toEqual([2]);
|
||||
});
|
||||
|
||||
// A rejected recipient is not pending: advancing to them would re-activate
|
||||
// and re-email somebody who declined to sign.
|
||||
it('excludes rejected recipients', () => {
|
||||
const recipients = [candidate(1, 1, SigningStatus.REJECTED), candidate(2, 2)];
|
||||
|
||||
expect(filterRecipientsInFirstSigningGroup(recipients).map((r) => r.id)).toEqual([2]);
|
||||
});
|
||||
|
||||
it('excludes CC recipients', () => {
|
||||
const recipients = [
|
||||
candidate(1, 1, SigningStatus.NOT_SIGNED, RecipientRole.CC),
|
||||
candidate(2, 2, SigningStatus.NOT_SIGNED),
|
||||
];
|
||||
|
||||
expect(filterRecipientsInFirstSigningGroup(recipients).map((r) => r.id)).toEqual([2]);
|
||||
});
|
||||
|
||||
it('returns an empty array when every recipient is signed or rejected', () => {
|
||||
const recipients = [candidate(1, 1, SigningStatus.SIGNED), candidate(2, 2, SigningStatus.REJECTED)];
|
||||
|
||||
expect(filterRecipientsInFirstSigningGroup(recipients)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNextDictatableRecipient', () => {
|
||||
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(getNextDictatableRecipient({ 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(getNextDictatableRecipient({ 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(getNextDictatableRecipient({ 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(getNextDictatableRecipient({ 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(getNextDictatableRecipient({ recipients, currentRecipientId: 1 })).toBeNull();
|
||||
expect(getNextDictatableRecipient({ recipients, currentRecipientId: 999 })).toBeNull();
|
||||
});
|
||||
|
||||
// The server picks the next recipient from a pending-only query, so an
|
||||
// already-signed recipient in a later step must be skipped here too —
|
||||
// otherwise the dictated name/email is applied to someone else.
|
||||
it('skips an already-signed recipient sitting in a later step', () => {
|
||||
const recipients = [
|
||||
recipient(1, 1, SigningStatus.NOT_SIGNED),
|
||||
recipient(2, 2, SigningStatus.SIGNED),
|
||||
recipient(3, 3, SigningStatus.NOT_SIGNED),
|
||||
];
|
||||
|
||||
expect(getNextDictatableRecipient({ recipients, currentRecipientId: 1 })?.id).toBe(3);
|
||||
});
|
||||
|
||||
it('ignores signed members when deciding whether the next step is a group', () => {
|
||||
const recipients = [
|
||||
recipient(1, 1, SigningStatus.NOT_SIGNED),
|
||||
recipient(2, 2, SigningStatus.SIGNED),
|
||||
recipient(3, 2, SigningStatus.NOT_SIGNED),
|
||||
];
|
||||
|
||||
// Only one member of step 2 is still pending, which is what the server would rename.
|
||||
expect(getNextDictatableRecipient({ recipients, currentRecipientId: 1 })?.id).toBe(3);
|
||||
});
|
||||
|
||||
it('returns null when every later recipient has signed', () => {
|
||||
const recipients = [recipient(1, 1, SigningStatus.NOT_SIGNED), recipient(2, 2, SigningStatus.SIGNED)];
|
||||
|
||||
expect(getNextDictatableRecipient({ recipients, currentRecipientId: 1 })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,482 @@
|
||||
import type { Recipient } from '@prisma/client';
|
||||
import { SigningStatus } 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;
|
||||
|
||||
/**
|
||||
* The signing order to sort and group by, treating "no order" as last.
|
||||
*
|
||||
* Exported so callers outside this module share one definition of the
|
||||
* null-as-last convention rather than restating it.
|
||||
*/
|
||||
export 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 };
|
||||
};
|
||||
|
||||
/**
|
||||
* Index of the last step containing a recipient that can no longer be modified,
|
||||
* or -1 when there is none.
|
||||
*
|
||||
* Signing is sequential, so anyone who has already acted sits at or before the
|
||||
* current step. Everything up to and including that step is therefore treated
|
||||
* as locked: those signing orders are persisted values the server will not let
|
||||
* us rewrite. Steps after it can only hold recipients who have not acted, so
|
||||
* they can be renumbered and reordered freely — no anchoring required.
|
||||
*
|
||||
* Stated as "up to and including the last locked step" rather than "the locked
|
||||
* prefix" deliberately: a locked recipient can appear out of sequence (direct
|
||||
* templates sign at their template order, field insertion has no turn check,
|
||||
* and a document can be switched from parallel to sequential mid-flight). This
|
||||
* form stays correct in those cases, just more conservative.
|
||||
*/
|
||||
export const getLastLockedStepIndex = <T extends GroupableRecipient>(
|
||||
steps: RecipientStep<T>[],
|
||||
canUpdateRecipient: (recipient: T) => boolean = () => true,
|
||||
): number =>
|
||||
steps.reduce(
|
||||
(lastIndex, step, index) => (step.members.some((member) => !canUpdateRecipient(member)) ? index : lastIndex),
|
||||
-1,
|
||||
);
|
||||
|
||||
/**
|
||||
* 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 lastLockedStepIndex = getLastLockedStepIndex(steps, canUpdateRecipient);
|
||||
|
||||
let nextOrder = 1;
|
||||
|
||||
const normalizedSteps = steps.map((step, index) => {
|
||||
// Locked steps hold persisted orders. Keep them exactly as they are, even
|
||||
// when sparse — renumbering them is what the server refuses.
|
||||
if (index <= lastLockedStepIndex) {
|
||||
const order = step.order === UNORDERED ? undefined : step.order;
|
||||
|
||||
if (order !== undefined) {
|
||||
nextOrder = Math.max(nextOrder, order + 1);
|
||||
}
|
||||
|
||||
return { order, members: step.members };
|
||||
}
|
||||
|
||||
const order = nextOrder;
|
||||
|
||||
nextOrder += 1;
|
||||
|
||||
return { order, members: step.members };
|
||||
});
|
||||
|
||||
return [
|
||||
...normalizedSteps.flatMap((step) => step.members.map((member) => ({ ...member, signingOrder: step.order }))),
|
||||
...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 = <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];
|
||||
const lastLockedStepIndex = getLastLockedStepIndex(steps, canUpdateRecipient);
|
||||
|
||||
if (
|
||||
!sourceStep ||
|
||||
!targetStep ||
|
||||
sourceStepIndex === targetStepIndex ||
|
||||
sourceStepIndex <= lastLockedStepIndex ||
|
||||
targetStepIndex <= lastLockedStepIndex
|
||||
) {
|
||||
return normalizeGroupedSigningOrders(recipients, canUpdateRecipient);
|
||||
}
|
||||
|
||||
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 = <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);
|
||||
const lastLockedStepIndex = getLastLockedStepIndex(steps, canUpdateRecipient);
|
||||
const moverStepIndex = steps.findIndex((step) => step.members.some((member) => member.formId === formId));
|
||||
|
||||
if (!targetStep || !mover || isCcRecipient(mover)) {
|
||||
return normalizeGroupedSigningOrders(recipients, canUpdateRecipient);
|
||||
}
|
||||
|
||||
// Neither the recipient nor the destination may sit in the locked region.
|
||||
if (targetStepIndex <= lastLockedStepIndex || moverStepIndex <= lastLockedStepIndex) {
|
||||
return normalizeGroupedSigningOrders(recipients, canUpdateRecipient);
|
||||
}
|
||||
|
||||
if (targetStep.members.some((member) => member.formId === formId)) {
|
||||
return normalizeGroupedSigningOrders(recipients, canUpdateRecipient);
|
||||
}
|
||||
|
||||
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 normalizeGroupedSigningOrders(recipients, canUpdateRecipient);
|
||||
}
|
||||
|
||||
const currentStepIndex = steps.findIndex((step) => step.members.some((member) => member.formId === formId));
|
||||
const isSoloStep = currentStepIndex !== -1 && steps[currentStepIndex].members.length === 1;
|
||||
const lastLockedStepIndex = getLastLockedStepIndex(steps, canUpdateRecipient);
|
||||
|
||||
// Dropping a solo step into the gap directly above or below itself is a no-op.
|
||||
if (isSoloStep && (insertStepIndex === currentStepIndex || insertStepIndex === currentStepIndex + 1)) {
|
||||
return normalizeGroupedSigningOrders(recipients, canUpdateRecipient);
|
||||
}
|
||||
|
||||
// Gap N sits before step N, so inserting at or before the last locked step
|
||||
// would land the recipient inside the locked region.
|
||||
if (insertStepIndex <= lastLockedStepIndex || currentStepIndex <= lastLockedStepIndex) {
|
||||
return normalizeGroupedSigningOrders(recipients, canUpdateRecipient);
|
||||
}
|
||||
|
||||
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.
|
||||
*
|
||||
* Refused when either end sits in the locked region (see
|
||||
* `getLastLockedStepIndex`); only the unlocked tail can be rearranged.
|
||||
*/
|
||||
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);
|
||||
|
||||
const lastLockedStepIndex = getLastLockedStepIndex(steps, canUpdateRecipient);
|
||||
|
||||
if (
|
||||
!steps[fromStepIndex] ||
|
||||
fromStepIndex === toStepIndex ||
|
||||
fromStepIndex <= lastLockedStepIndex ||
|
||||
toStepIndex <= lastLockedStepIndex
|
||||
) {
|
||||
return normalizeGroupedSigningOrders(recipients, canUpdateRecipient);
|
||||
}
|
||||
|
||||
const reorderedSteps = [...steps];
|
||||
const [movedStep] = reorderedSteps.splice(fromStepIndex, 1);
|
||||
|
||||
reorderedSteps.splice(Math.min(toStepIndex, reorderedSteps.length), 0, movedStep);
|
||||
|
||||
// Locked steps cannot be the source or destination, so they keep both their
|
||||
// position and their persisted order. The moved tail is numbered above the
|
||||
// highest locked order so it still sorts after them.
|
||||
const highestLockedOrder = reorderedSteps
|
||||
.slice(0, lastLockedStepIndex + 1)
|
||||
.reduce((highest, step) => (step.order === UNORDERED ? highest : Math.max(highest, step.order)), 0);
|
||||
|
||||
const updated = [
|
||||
...reorderedSteps.flatMap((step, index) => {
|
||||
if (index <= lastLockedStepIndex) {
|
||||
return step.members;
|
||||
}
|
||||
|
||||
const order = highestLockedOrder + (index - lastLockedStepIndex);
|
||||
|
||||
return step.members.map((member) => ({ ...member, signingOrder: order }));
|
||||
}),
|
||||
...ccRecipients,
|
||||
];
|
||||
|
||||
return normalizeGroupedSigningOrders(updated, canUpdateRecipient);
|
||||
};
|
||||
|
||||
/**
|
||||
* The signing order changes needed to give every signing recipient a step of
|
||||
* their own, or an empty array when none share one.
|
||||
*
|
||||
* Used to repair an envelope that must not contain signing groups (AES/QES).
|
||||
* Recipients keep their relative sequence — ordered by signing order, ties
|
||||
* broken by id, matching how the server sorts them everywhere else — and are
|
||||
* renumbered densely from 1. Orders are only rewritten when a step is actually
|
||||
* shared, so a valid-but-sparse sequence is left alone.
|
||||
*
|
||||
* CC recipients are excluded: they never sign and carry no step.
|
||||
*/
|
||||
export const flattenRecipientGroups = <T extends Pick<Recipient, 'id' | 'role'> & { signingOrder?: number | null }>(
|
||||
recipients: T[],
|
||||
): Array<{ id: number; signingOrder: number }> => {
|
||||
const signingRecipients = recipients
|
||||
.filter((recipient) => !isCcRecipient(recipient))
|
||||
.sort((a, b) => effectiveOrder(a) - effectiveOrder(b) || a.id - b.id);
|
||||
|
||||
const sharesAStep = new Set(signingRecipients.map(effectiveOrder)).size !== signingRecipients.length;
|
||||
|
||||
if (!sharesAStep) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const changes: Array<{ id: number; signingOrder: number }> = [];
|
||||
|
||||
signingRecipients.forEach((recipient, index) => {
|
||||
const signingOrder = index + 1;
|
||||
|
||||
if (recipient.signingOrder !== signingOrder) {
|
||||
changes.push({ id: recipient.id, signingOrder });
|
||||
}
|
||||
});
|
||||
|
||||
return changes;
|
||||
};
|
||||
|
||||
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".
|
||||
*
|
||||
* Pass the full recipient list: filtering happens here so every caller agrees
|
||||
* on what "pending" means. A recipient is pending when they are not a CC and
|
||||
* have not signed OR rejected — advancing to a rejected recipient would
|
||||
* re-activate and re-email somebody who declined to sign.
|
||||
*/
|
||||
export const filterRecipientsInFirstSigningGroup = <T extends SignableRecipient>(recipients: T[]): T[] => {
|
||||
const pendingRecipients = recipients.filter(
|
||||
(recipient) => !isCcRecipient(recipient) && recipient.signingStatus === SigningStatus.NOT_SIGNED,
|
||||
);
|
||||
|
||||
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 getNextDictatableRecipient = <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;
|
||||
}
|
||||
|
||||
// Only the step matters here; `filterRecipientsInFirstSigningGroup` drops
|
||||
// CCs and anyone who has already signed or rejected.
|
||||
const laterRecipients = recipients.filter((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.
|
||||
*/
|
||||
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];
|
||||
|
||||
// Splitting a locked step would rewrite persisted orders.
|
||||
if (!step || step.members.length < 2 || stepIndex <= getLastLockedStepIndex(steps, canUpdateRecipient)) {
|
||||
return normalizeGroupedSigningOrders(recipients, canUpdateRecipient);
|
||||
}
|
||||
|
||||
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);
|
||||
};
|
||||
@@ -51,6 +51,24 @@ describe('recipient signing order helpers', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it('checks whether the last non-CC recipient is an assistant', () => {
|
||||
expect(
|
||||
isAssistantLastSigner([
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
|
||||
import type { Envelope, Field, Recipient } from '@prisma/client';
|
||||
import { RecipientRole, SigningStatus } from '@prisma/client';
|
||||
import type { Envelope, Field, Prisma, Recipient } from '@prisma/client';
|
||||
import { EnvelopeType, RecipientRole, SigningStatus } from '@prisma/client';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
|
||||
import { AppError, AppErrorCode } from '../errors/app-error';
|
||||
import type { TEditorEnvelope } from '../types/envelope-editor';
|
||||
import type { TRecipientLite } from '../types/recipient';
|
||||
import { extractLegacyIds } from '../universal/id';
|
||||
import { zEmail } from './zod';
|
||||
@@ -22,11 +23,100 @@ export const isCcRecipient = (recipient: Pick<Recipient, 'role'>) => {
|
||||
return recipient.role === RecipientRole.CC;
|
||||
};
|
||||
|
||||
export const isAssistantLastSigner = (recipients: Pick<Recipient, 'role'>[]) => {
|
||||
/**
|
||||
* 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));
|
||||
const lastNonCcRecipient = nonCcRecipients[nonCcRecipients.length - 1];
|
||||
|
||||
return lastNonCcRecipient?.role === RecipientRole.ASSISTANT;
|
||||
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,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Prisma `where` input matching recipients in strictly LATER signing steps
|
||||
* than the assistant, under the null-as-last convention shared with
|
||||
* `effectiveOrder`: a missing signing order means the LAST step.
|
||||
*
|
||||
* - An assistant with a numeric order reaches recipients with a greater
|
||||
* numeric order, plus null-order recipients (the tail step).
|
||||
* - A null-order assistant sits in the last step themselves: nobody comes
|
||||
* after them, so this matches nothing.
|
||||
*
|
||||
* The historical inline filters used `signingOrder ?? 0`, which encoded
|
||||
* "null = first" — hiding tail-step recipients from ordered assistants and
|
||||
* letting a null-order assistant reach every ordered (earlier) recipient.
|
||||
*
|
||||
* Deliberately applied regardless of the document's signing order mode:
|
||||
* restricting assistants by step on PARALLEL documents mirrors the
|
||||
* historical behavior and fails closed. Whether parallel documents should
|
||||
* lift the restriction entirely is an open product decision.
|
||||
*/
|
||||
export const getLaterSigningStepRecipientsWhereInput = (
|
||||
assistant: Pick<Recipient, 'signingOrder'>,
|
||||
): Prisma.RecipientWhereInput => {
|
||||
if (assistant.signingOrder === null) {
|
||||
// Matches nothing.
|
||||
return { id: { in: [] } };
|
||||
}
|
||||
|
||||
return {
|
||||
OR: [{ signingOrder: { gt: assistant.signingOrder } }, { signingOrder: null }],
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Prisma `where` input matching every recipient an assistant may act for:
|
||||
* themself, plus recipients in strictly later steps — never their own group
|
||||
* peers.
|
||||
*/
|
||||
export const getAssistableRecipientsWhereInput = (
|
||||
assistant: Pick<Recipient, 'id' | 'signingOrder'>,
|
||||
): Prisma.RecipientWhereInput => ({
|
||||
OR: [{ id: assistant.id }, getLaterSigningStepRecipientsWhereInput(assistant)],
|
||||
});
|
||||
|
||||
/**
|
||||
* Prisma `where` input matching the recipients whose fields the token holder
|
||||
* may act on: non-assistants may only act on their own fields, while
|
||||
* assistants may also act on fields of unsigned recipients in strictly later
|
||||
* steps.
|
||||
*
|
||||
* Shared by every field-level endpoint (sign / uninsert, V1 and V2) so the
|
||||
* scoping rule cannot drift between them.
|
||||
*/
|
||||
export const getFieldOwnerWhereInput = (
|
||||
recipient: Pick<Recipient, 'id' | 'role' | 'signingOrder' | 'envelopeId'>,
|
||||
): Prisma.RecipientWhereInput => {
|
||||
if (recipient.role !== RecipientRole.ASSISTANT) {
|
||||
return { id: recipient.id };
|
||||
}
|
||||
|
||||
return {
|
||||
signingStatus: {
|
||||
not: SigningStatus.SIGNED,
|
||||
},
|
||||
envelopeId: recipient.envelopeId,
|
||||
AND: [getAssistableRecipientsWhereInput(recipient)],
|
||||
};
|
||||
};
|
||||
|
||||
export const sortRecipientsForSigningOrder = <T extends RecipientWithSigningOrder>(recipients: T[]): T[] => {
|
||||
@@ -120,6 +210,36 @@ export const canRecipientBeModified = (
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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);
|
||||
|
||||
// The envelope lags behind the form: a recipient the editor has just created
|
||||
// is not in it yet. Such a recipient cannot have acted on the document, so
|
||||
// treat an unknown id as modifiable — reporting it as locked would freeze
|
||||
// reordering for a document nobody has signed.
|
||||
if (!recipient) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return canRecipientBeModified(recipient, envelope.fields);
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether a recipient can have their fields modified by the document owner.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user