mirror of
https://github.com/documenso/documenso.git
synced 2026-08-24 15:22:26 +10:00
feat: add group-aware turn and dictation helpers
This commit is contained in:
@@ -1,9 +1,12 @@
|
|||||||
import { RecipientRole } from '@prisma/client';
|
import { RecipientRole, SigningStatus } from '@prisma/client';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
extractRecipientToNewStep,
|
extractRecipientToNewStep,
|
||||||
|
filterRecipientsInFirstSigningGroup,
|
||||||
|
getDictatableNextRecipient,
|
||||||
groupRecipientsBySigningOrder,
|
groupRecipientsBySigningOrder,
|
||||||
|
isRecipientTurnBySigningOrder,
|
||||||
mergeSteps,
|
mergeSteps,
|
||||||
moveRecipientToStep,
|
moveRecipientToStep,
|
||||||
normalizeGroupedSigningOrders,
|
normalizeGroupedSigningOrders,
|
||||||
@@ -327,3 +330,137 @@ describe('ungroupStep', () => {
|
|||||||
expect(ordersOf(ungroupStep(signers, 0))).toEqual(ordersOf(signers));
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Recipient } from '@prisma/client';
|
import type { Recipient } from '@prisma/client';
|
||||||
|
import { SigningStatus } from '@prisma/client';
|
||||||
|
|
||||||
import { isCcRecipient } from './recipients';
|
import { isCcRecipient } from './recipients';
|
||||||
|
|
||||||
@@ -261,6 +262,96 @@ export const reorderStep = <T extends EditorRecipient>(
|
|||||||
return normalizeGroupedSigningOrders(updated, canUpdateRecipient);
|
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.
|
* Dissolves a group into consecutive standalone steps preserving relative order.
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user