mirror of
https://github.com/documenso/documenso.git
synced 2026-08-27 00:32:29 +10:00
fix: wip
This commit is contained in:
@@ -9,7 +9,8 @@ import type { UseFormReturn } from 'react-hook-form';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { isCcRecipient, normalizeRecipientSigningOrders, sortRecipientsForSigningOrder } from '../../utils/recipients';
|
||||
import { normalizeGroupedSigningOrders } from '../../utils/recipient-groups';
|
||||
import { isCcRecipient, sortRecipientsForSigningOrder } from '../../utils/recipients';
|
||||
|
||||
const LocalRecipientSchema = z.object({
|
||||
formId: z.string().min(1),
|
||||
@@ -65,10 +66,71 @@ export const ZEditorRecipientsFormSchema = z
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const seenSigningOrders = new Set<number>();
|
||||
|
||||
data.signers.forEach((signer, index) => {
|
||||
if (signer.role === RecipientRole.CC || typeof signer.signingOrder !== 'number') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (seenSigningOrders.has(signer.signingOrder)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'CSC envelopes do not support recipient signing groups.',
|
||||
path: ['signers', index, 'signingOrder'],
|
||||
});
|
||||
}
|
||||
|
||||
seenSigningOrders.add(signer.signingOrder);
|
||||
});
|
||||
});
|
||||
|
||||
export type TEditorRecipientsFormSchema = z.infer<typeof ZEditorRecipientsFormSchema>;
|
||||
|
||||
/**
|
||||
* Replaces the signers array while keeping controlled inputs in sync.
|
||||
*
|
||||
* Rows are rendered with stable `formId` keys (required for drag and drop),
|
||||
* so react-hook-form `Controller`s never remount and their leaf
|
||||
* subscriptions are NOT re-notified by a root-level array `setValue`. Any
|
||||
* value that changes while a signer keeps its index (e.g. a role change)
|
||||
* must be leaf-set first so the controlled input actually re-renders.
|
||||
*/
|
||||
export const updateEditorSigners = (
|
||||
form: UseFormReturn<TEditorRecipientsFormSchema>,
|
||||
updatedSigners: TEditorRecipientsFormSchema['signers'],
|
||||
) => {
|
||||
const previousSigners = form.getValues('signers');
|
||||
|
||||
updatedSigners.forEach((signer, index) => {
|
||||
const previousSigner = previousSigners[index];
|
||||
|
||||
// Only slot-stable signers need leaf notifications — moved signers get a
|
||||
// new field name and re-subscribe with fresh values on their own.
|
||||
if (!previousSigner || previousSigner.formId !== signer.formId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (previousSigner.role !== signer.role) {
|
||||
form.setValue(`signers.${index}.role`, signer.role, { shouldDirty: true });
|
||||
}
|
||||
|
||||
if (previousSigner.email !== signer.email) {
|
||||
form.setValue(`signers.${index}.email`, signer.email, { shouldDirty: true });
|
||||
}
|
||||
|
||||
if (previousSigner.name !== signer.name) {
|
||||
form.setValue(`signers.${index}.name`, signer.name, { shouldDirty: true });
|
||||
}
|
||||
});
|
||||
|
||||
form.setValue('signers', updatedSigners, {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
};
|
||||
|
||||
type EditorRecipientsProps = {
|
||||
envelope: TEditorEnvelope;
|
||||
};
|
||||
@@ -89,19 +151,43 @@ export const useEditorRecipients = ({ envelope }: EditorRecipientsProps): UseEdi
|
||||
const generateDefaultValues = (options?: ResetFormOptions) => {
|
||||
const { recipients, documentMeta } = options ?? {};
|
||||
|
||||
const formRecipients = (recipients || envelope.recipients).map((recipient, index) => ({
|
||||
const sourceRecipients = sortRecipientsForSigningOrder(recipients || envelope.recipients);
|
||||
|
||||
// A recipient without a persisted order means "last" everywhere else — the
|
||||
// server sorts NULLS LAST. Continue numbering after the highest existing
|
||||
// order rather than guessing from array position: a guess can land on a
|
||||
// real order, and equal orders now mean "same signing step".
|
||||
let fallbackOrder = sourceRecipients.reduce(
|
||||
(highest, recipient) => Math.max(highest, recipient.signingOrder ?? 0),
|
||||
0,
|
||||
);
|
||||
|
||||
const signingOrderByRecipientId = new Map<number, number | undefined>();
|
||||
|
||||
for (const recipient of sourceRecipients) {
|
||||
if (isCcRecipient(recipient)) {
|
||||
signingOrderByRecipientId.set(recipient.id, undefined);
|
||||
} else if (typeof recipient.signingOrder === 'number') {
|
||||
signingOrderByRecipientId.set(recipient.id, recipient.signingOrder);
|
||||
} else {
|
||||
fallbackOrder += 1;
|
||||
signingOrderByRecipientId.set(recipient.id, fallbackOrder);
|
||||
}
|
||||
}
|
||||
|
||||
const formRecipients = sourceRecipients.map((recipient) => ({
|
||||
id: recipient.id,
|
||||
formId: String(recipient.id),
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
role: recipient.role,
|
||||
signingOrder: isCcRecipient(recipient) ? undefined : (recipient.signingOrder ?? index + 1),
|
||||
signingOrder: signingOrderByRecipientId.get(recipient.id),
|
||||
actionAuth: ZRecipientAuthOptionsSchema.parse(recipient.authOptions)?.actionAuth ?? undefined,
|
||||
}));
|
||||
|
||||
const signers: TLocalRecipient[] =
|
||||
formRecipients.length > 0
|
||||
? normalizeRecipientSigningOrders(sortRecipientsForSigningOrder(formRecipients))
|
||||
? normalizeGroupedSigningOrders(formRecipients)
|
||||
: [
|
||||
{
|
||||
formId: initialId,
|
||||
|
||||
@@ -7,7 +7,10 @@ import {
|
||||
} from '@documenso/lib/types/envelope-editor';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TSetEnvelopeFieldsResponse } from '@documenso/trpc/server/envelope-router/set-envelope-fields.types';
|
||||
import type { TSetEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/set-envelope-recipients.types';
|
||||
import type {
|
||||
TSetEnvelopeRecipientsRequest,
|
||||
TSetEnvelopeRecipientsResponse,
|
||||
} from '@documenso/trpc/server/envelope-router/set-envelope-recipients.types';
|
||||
import type { TUpdateEnvelopeRequest } from '@documenso/trpc/server/envelope-router/update-envelope.types';
|
||||
import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors';
|
||||
import { getRecipientColor } from '@documenso/ui/lib/recipient-colors';
|
||||
@@ -29,6 +32,14 @@ export type EnvelopeEditorStep = 'upload' | 'addFields' | 'preview';
|
||||
|
||||
type UpdateEnvelopePayload = Pick<TUpdateEnvelopeRequest, 'data' | 'meta'>;
|
||||
|
||||
type SetRecipientsPayload = (TSetEnvelopeRecipientsRequest['recipients'][number] & {
|
||||
/**
|
||||
* Stable client-side key for the signer row, echoed to the server as
|
||||
* `clientId` so newly created recipients can adopt their server-assigned id.
|
||||
*/
|
||||
formId?: string;
|
||||
})[];
|
||||
|
||||
type EnvelopeEditorProviderValue = {
|
||||
editorConfig: EnvelopeEditorConfig;
|
||||
|
||||
@@ -47,8 +58,8 @@ type EnvelopeEditorProviderValue = {
|
||||
setLocalEnvelope: (localEnvelope: Partial<TEditorEnvelope>) => void;
|
||||
updateEnvelope: (envelopeUpdates: UpdateEnvelopePayload) => void;
|
||||
updateEnvelopeAsync: (envelopeUpdates: UpdateEnvelopePayload) => Promise<void>;
|
||||
setRecipientsDebounced: (recipients: TSetEnvelopeRecipientsRequest['recipients']) => void;
|
||||
setRecipientsAsync: (recipients: TSetEnvelopeRecipientsRequest['recipients']) => Promise<void>;
|
||||
setRecipientsDebounced: (recipients: SetRecipientsPayload) => void;
|
||||
setRecipientsAsync: (recipients: SetRecipientsPayload) => Promise<void>;
|
||||
|
||||
getRecipientColorKey: (recipientId: number) => TRecipientColor;
|
||||
|
||||
@@ -170,6 +181,41 @@ export const EnvelopeEditorProvider = ({
|
||||
const externalFlushCallbacksRef = useRef<Map<string, () => Promise<void>>>(new Map());
|
||||
const pendingMutationsRef = useRef<Set<Promise<unknown>>>(new Set());
|
||||
|
||||
/**
|
||||
* Server-assigned ids for signers created during this session, keyed by
|
||||
* their stable formId. The recipients form only learns real ids on a form
|
||||
* reset (step navigation), so this map bridges the gap between autosaves.
|
||||
*/
|
||||
const recipientIdByFormIdRef = useRef<Map<string, number>>(new Map());
|
||||
|
||||
/**
|
||||
* Merges known server-assigned ids into the outgoing payload. Without
|
||||
* this, a newly created signer (id-less in the form until the next form
|
||||
* reset) would be deleted and recreated on every autosave — churning ids,
|
||||
* signing tokens and the audit log. The formId doubles as the `clientId`
|
||||
* echoed back by the server.
|
||||
*/
|
||||
const withKnownRecipientIds = (localRecipients: SetRecipientsPayload) =>
|
||||
localRecipients.map((recipient) => ({
|
||||
...recipient,
|
||||
id: recipient.id ?? (recipient.formId ? recipientIdByFormIdRef.current.get(recipient.formId) : undefined),
|
||||
clientId: recipient.formId,
|
||||
}));
|
||||
|
||||
/**
|
||||
* Records the ids of newly created recipients from the save response. A
|
||||
* ref — rather than writing ids back into the form — is deliberate: the
|
||||
* response can land mid interaction, and a form write here re-renders the
|
||||
* signer rows, which breaks an in-progress recipient drag.
|
||||
*/
|
||||
const rememberCreatedRecipientIds = (recipients: TSetEnvelopeRecipientsResponse['data']) => {
|
||||
for (const recipient of recipients) {
|
||||
if (recipient.clientId) {
|
||||
recipientIdByFormIdRef.current.set(recipient.clientId, recipient.id);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const registerExternalFlush = useCallback((key: string, flush: () => Promise<void>) => {
|
||||
externalFlushCallbacksRef.current.set(key, flush);
|
||||
|
||||
@@ -210,7 +256,7 @@ export const EnvelopeEditorProvider = ({
|
||||
triggerSave: setRecipientsDebounced,
|
||||
flush: flushSetRecipients,
|
||||
isPending: isRecipientsMutationPending,
|
||||
} = useEnvelopeAutosave(async (localRecipients: TSetEnvelopeRecipientsRequest['recipients']) => {
|
||||
} = useEnvelopeAutosave(async (localRecipients: SetRecipientsPayload) => {
|
||||
try {
|
||||
let recipients: TEditorEnvelope['recipients'] = [];
|
||||
|
||||
@@ -220,10 +266,12 @@ export const EnvelopeEditorProvider = ({
|
||||
const response = await setRecipientsMutation.mutateAsync({
|
||||
envelopeId: currentEnvelope.id,
|
||||
envelopeType: currentEnvelope.type,
|
||||
recipients: localRecipients,
|
||||
recipients: withKnownRecipientIds(localRecipients),
|
||||
});
|
||||
|
||||
recipients = response.data;
|
||||
|
||||
rememberCreatedRecipientIds(response.data);
|
||||
} else {
|
||||
recipients = mapLocalRecipientsToRecipients({ envelope: currentEnvelope, localRecipients });
|
||||
}
|
||||
@@ -252,7 +300,7 @@ export const EnvelopeEditorProvider = ({
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
const setRecipientsAsync = async (localRecipients: TSetEnvelopeRecipientsRequest['recipients']) => {
|
||||
const setRecipientsAsync = async (localRecipients: SetRecipientsPayload) => {
|
||||
setRecipientsDebounced(localRecipients);
|
||||
await flushSetRecipients();
|
||||
};
|
||||
|
||||
@@ -25,6 +25,7 @@ import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../
|
||||
import { extractDocumentAuthMethods } from '../../utils/document-auth';
|
||||
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
||||
import { mapSecondaryIdToDocumentId, unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
|
||||
import { filterRecipientsInFirstSigningGroup } from '../../utils/recipient-groups';
|
||||
import { assertRecipientNotExpired } from '../../utils/recipients';
|
||||
import { getIsRecipientsTurnToSign } from '../recipient/get-is-recipient-turn';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
@@ -423,6 +424,7 @@ export const completeDocumentWithToken = async ({
|
||||
select: {
|
||||
id: true,
|
||||
signingOrder: true,
|
||||
signingStatus: true,
|
||||
name: true,
|
||||
email: true,
|
||||
role: true,
|
||||
@@ -451,65 +453,87 @@ export const completeDocumentWithToken = async ({
|
||||
});
|
||||
|
||||
if (envelope.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL) {
|
||||
const [nextRecipient] = pendingRecipients;
|
||||
// The next group: every pending recipient sharing the lowest pending
|
||||
// signing order. If the completing recipient's own step is still
|
||||
// pending (a group peer has not signed yet), the flow does not advance —
|
||||
// the remaining peers were already activated when their step unlocked.
|
||||
const nextGroup = filterRecipientsInFirstSigningGroup(pendingRecipients);
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
if (nextSigner && envelope.documentMeta?.allowDictateNextSigner) {
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
|
||||
envelopeId: envelope.id,
|
||||
user: {
|
||||
name: recipientName,
|
||||
email: recipientEmail,
|
||||
},
|
||||
requestMetadata,
|
||||
const currentRecipientOrder = recipient.signingOrder ?? Number.MAX_SAFE_INTEGER;
|
||||
|
||||
const hasCompletedCurrentStep = nextGroup.every(
|
||||
(pendingRecipient) => (pendingRecipient.signingOrder ?? Number.MAX_SAFE_INTEGER) > currentRecipientOrder,
|
||||
);
|
||||
|
||||
if (nextGroup.length > 0 && hasCompletedCurrentStep) {
|
||||
// Dictation only applies when advancing to a single-recipient step.
|
||||
const canDictateNextSigner =
|
||||
Boolean(nextSigner) && Boolean(envelope.documentMeta?.allowDictateNextSigner) && nextGroup.length === 1;
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
if (canDictateNextSigner && nextSigner) {
|
||||
const [nextRecipient] = nextGroup;
|
||||
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
|
||||
envelopeId: envelope.id,
|
||||
user: {
|
||||
name: recipientName,
|
||||
email: recipientEmail,
|
||||
},
|
||||
requestMetadata,
|
||||
data: {
|
||||
recipientEmail: nextRecipient.email,
|
||||
recipientName: nextRecipient.name,
|
||||
recipientId: nextRecipient.id,
|
||||
recipientRole: nextRecipient.role,
|
||||
changes: [
|
||||
{
|
||||
type: RECIPIENT_DIFF_TYPE.NAME,
|
||||
from: nextRecipient.name,
|
||||
to: nextSigner.name,
|
||||
},
|
||||
{
|
||||
type: RECIPIENT_DIFF_TYPE.EMAIL,
|
||||
from: nextRecipient.email,
|
||||
to: nextSigner.email,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
for (const nextRecipient of nextGroup) {
|
||||
await tx.recipient.update({
|
||||
where: { id: nextRecipient.id },
|
||||
data: {
|
||||
recipientEmail: nextRecipient.email,
|
||||
recipientName: nextRecipient.name,
|
||||
recipientId: nextRecipient.id,
|
||||
recipientRole: nextRecipient.role,
|
||||
changes: [
|
||||
{
|
||||
type: RECIPIENT_DIFF_TYPE.NAME,
|
||||
from: nextRecipient.name,
|
||||
to: nextSigner.name,
|
||||
},
|
||||
{
|
||||
type: RECIPIENT_DIFF_TYPE.EMAIL,
|
||||
from: nextRecipient.email,
|
||||
to: nextSigner.email,
|
||||
},
|
||||
],
|
||||
sendStatus: SendStatus.SENT,
|
||||
sentAt: new Date(),
|
||||
...(canDictateNextSigner && nextSigner
|
||||
? {
|
||||
name: nextSigner.name,
|
||||
email: nextSigner.email,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
for (const nextRecipient of nextGroup) {
|
||||
await jobs.triggerJob({
|
||||
name: 'send.signing.requested.email',
|
||||
payload: {
|
||||
userId: envelope.userId,
|
||||
documentId: legacyDocumentId,
|
||||
recipientId: nextRecipient.id,
|
||||
requestMetadata,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await tx.recipient.update({
|
||||
where: { id: nextRecipient.id },
|
||||
data: {
|
||||
sendStatus: SendStatus.SENT,
|
||||
sentAt: new Date(),
|
||||
...(nextSigner && envelope.documentMeta?.allowDictateNextSigner
|
||||
? {
|
||||
name: nextSigner.name,
|
||||
email: nextSigner.email,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await jobs.triggerJob({
|
||||
name: 'send.signing.requested.email',
|
||||
payload: {
|
||||
userId: envelope.userId,
|
||||
documentId: legacyDocumentId,
|
||||
recipientId: nextRecipient.id,
|
||||
requestMetadata,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ import { isDocumentCompleted } from '../../utils/document';
|
||||
import { extractDocumentAuthMethods } from '../../utils/document-auth';
|
||||
import { type EnvelopeIdOptions, mapSecondaryIdToDocumentId } from '../../utils/envelope';
|
||||
import { toCheckboxCustomText, toRadioCustomText } from '../../utils/fields';
|
||||
import { filterRecipientsInFirstSigningGroup, flattenRecipientGroups } from '../../utils/recipient-groups';
|
||||
import { getRecipientsWithMissingFields, isRecipientEmailValidForSending } from '../../utils/recipients';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
|
||||
@@ -147,13 +148,38 @@ export const sendDocument = async ({ id, userId, teamId, sendEmail, requestMetad
|
||||
envelope.documentMeta.signingOrder = DocumentSigningOrder.SEQUENTIAL;
|
||||
}
|
||||
|
||||
// Signing groups cannot exist on a TSP envelope: two recipients sharing a
|
||||
// step sign in parallel, which breaks the per-recipient /ByteRange invariant.
|
||||
// The schema-layer guard should have caught this at write time; flatten the
|
||||
// groups here so an envelope that slipped through is still distributable.
|
||||
if (isTspEnvelope(envelope)) {
|
||||
const flattenedOrders = flattenRecipientGroups(envelope.recipients);
|
||||
|
||||
if (flattenedOrders.length > 0) {
|
||||
console.warn(
|
||||
`[CSC] Coercing ${flattenedOrders.length} grouped recipient(s) to distinct signing orders for ${envelope.signatureLevel} envelope ${envelope.id} at send time. The schema-layer guard should have caught this earlier.`,
|
||||
);
|
||||
|
||||
await prisma.$transaction(
|
||||
flattenedOrders.map(({ id, signingOrder: order }) =>
|
||||
prisma.recipient.update({ where: { id }, data: { signingOrder: order } }),
|
||||
),
|
||||
);
|
||||
|
||||
envelope.recipients = envelope.recipients.map((recipient) => {
|
||||
const flattened = flattenedOrders.find((entry) => entry.id === recipient.id);
|
||||
|
||||
return flattened ? { ...recipient, signingOrder: flattened.signingOrder } : recipient;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let recipientsToNotify = envelope.recipients;
|
||||
|
||||
if (signingOrder === DocumentSigningOrder.SEQUENTIAL) {
|
||||
// Get the currently active recipient.
|
||||
recipientsToNotify = envelope.recipients
|
||||
.filter((r) => r.signingStatus === SigningStatus.NOT_SIGNED && r.role !== RecipientRole.CC)
|
||||
.slice(0, 1);
|
||||
// Get the currently active signing group. Recipients sharing the lowest
|
||||
// pending signing order act in parallel within their group.
|
||||
recipientsToNotify = filterRecipientsInFirstSigningGroup(envelope.recipients);
|
||||
}
|
||||
|
||||
if (envelope.envelopeItems.length === 0) {
|
||||
|
||||
@@ -38,7 +38,9 @@ import { createDocumentAuthOptions, createRecipientAuthOptions } from '../../uti
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
import { incrementDocumentId, incrementTemplateId } from '../envelope/increment-id';
|
||||
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
|
||||
import { assertCompatibleRecipientGrouping } from '../signature-level/assert-compatible-recipient-grouping';
|
||||
import { assertCompatibleRecipientRole } from '../signature-level/assert-compatible-recipient-role';
|
||||
import { assignDefaultRecipientSigningOrders } from '../signature-level/assign-default-recipient-signing-orders';
|
||||
import { resolveSignatureLevel } from '../signature-level/resolve-signature-level';
|
||||
import { getTeamSettings } from '../team/get-team-settings';
|
||||
import { assertUserNotDisabledById } from '../user/assert-user-not-disabled';
|
||||
@@ -280,6 +282,32 @@ export const createEnvelope = async ({
|
||||
assertCompatibleRecipientRole({ signatureLevel, role: recipient.role });
|
||||
}
|
||||
|
||||
const parsedDefaultRecipients =
|
||||
settings.defaultRecipients && !bypassDefaultRecipients
|
||||
? ZDefaultRecipientsSchema.parse(settings.defaultRecipients)
|
||||
: [];
|
||||
|
||||
const defaultRecipients: CreateEnvelopeRecipientOptions[] = parsedDefaultRecipients.map((recipient) => ({
|
||||
email: recipient.email,
|
||||
name: recipient.name,
|
||||
role: recipient.role,
|
||||
}));
|
||||
|
||||
// Team default recipients carry no signing order, which on a TSP envelope
|
||||
// would land them all in the shared tail step — a signing group — so they
|
||||
// are numbered after the payload's highest order.
|
||||
const orderedDefaultRecipients = assignDefaultRecipientSigningOrders({
|
||||
signatureLevel,
|
||||
payloadRecipients: data.recipients ?? [],
|
||||
defaultRecipients,
|
||||
});
|
||||
|
||||
const allRecipientsToCreate = [...(data.recipients || []), ...orderedDefaultRecipients];
|
||||
|
||||
// The grouping assertion runs against the COMBINED set the envelope will
|
||||
// actually hold, not just the payload.
|
||||
assertCompatibleRecipientGrouping({ signatureLevel, recipients: allRecipientsToCreate });
|
||||
|
||||
const visibility = visibilityOverride || settings.documentVisibility;
|
||||
|
||||
const emailId = meta?.emailId;
|
||||
@@ -403,21 +431,8 @@ export const createEnvelope = async ({
|
||||
|
||||
const firstEnvelopeItem = envelope.envelopeItems[0];
|
||||
|
||||
const defaultRecipients =
|
||||
settings.defaultRecipients && !bypassDefaultRecipients
|
||||
? ZDefaultRecipientsSchema.parse(settings.defaultRecipients)
|
||||
: [];
|
||||
|
||||
const mappedDefaultRecipients: CreateEnvelopeRecipientOptions[] = defaultRecipients.map((recipient) => ({
|
||||
email: recipient.email,
|
||||
name: recipient.name,
|
||||
role: recipient.role,
|
||||
}));
|
||||
|
||||
const allRecipients = [...(data.recipients || []), ...mappedDefaultRecipients];
|
||||
|
||||
await Promise.all(
|
||||
allRecipients.map(async (recipient) => {
|
||||
allRecipientsToCreate.map(async (recipient) => {
|
||||
const recipientAuthOptions = createRecipientAuthOptions({
|
||||
accessAuth: recipient.accessAuth ?? [],
|
||||
actionAuth: recipient.actionAuth ?? [],
|
||||
|
||||
@@ -5,13 +5,14 @@ import EnvelopeSchema from '@documenso/prisma/generated/zod/modelSchema/Envelope
|
||||
import SignatureSchema from '@documenso/prisma/generated/zod/modelSchema/SignatureSchema';
|
||||
import TeamSchema from '@documenso/prisma/generated/zod/modelSchema/TeamSchema';
|
||||
import UserSchema from '@documenso/prisma/generated/zod/modelSchema/UserSchema';
|
||||
import { DocumentSigningOrder, DocumentStatus, EnvelopeType, RecipientRole, SigningStatus } from '@prisma/client';
|
||||
import { DocumentSigningOrder, DocumentStatus, EnvelopeType, SigningStatus } from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import type { TDocumentAuthMethods } from '../../types/document-auth';
|
||||
import { ZEnvelopeFieldSchema, ZFieldSchema } from '../../types/field';
|
||||
import { ZRecipientLiteSchema } from '../../types/recipient';
|
||||
import { isRecipientTurnBySigningOrder } from '../../utils/recipient-groups';
|
||||
import { isRecipientExpired } from '../../utils/recipients';
|
||||
import { isRecipientAuthorized } from '../document/is-recipient-authorized';
|
||||
import { getTeamSettings } from '../team/get-team-settings';
|
||||
@@ -260,23 +261,9 @@ export const getEnvelopeForRecipientSigning = async ({
|
||||
},
|
||||
});
|
||||
|
||||
let isRecipientsTurn = true;
|
||||
|
||||
const currentRecipientIndex = envelope.recipients.findIndex((r) => r.token === token);
|
||||
|
||||
if (envelope.documentMeta.signingOrder === DocumentSigningOrder.SEQUENTIAL && currentRecipientIndex !== -1) {
|
||||
for (let i = 0; i < currentRecipientIndex; i++) {
|
||||
// CC recipients have no action to take, so they can never block the flow.
|
||||
if (envelope.recipients[i].role === RecipientRole.CC) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (envelope.recipients[i].signingStatus !== SigningStatus.SIGNED) {
|
||||
isRecipientsTurn = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const isRecipientsTurn =
|
||||
envelope.documentMeta.signingOrder !== DocumentSigningOrder.SEQUENTIAL ||
|
||||
isRecipientTurnBySigningOrder(envelope.recipients, recipient);
|
||||
|
||||
const sender = settings.includeSenderDetails
|
||||
? {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { EnvelopeType, FieldType, RecipientRole, SigningStatus } from '@prisma/client';
|
||||
|
||||
import { getLaterSigningStepRecipientsWhereInput } from '../../utils/recipients';
|
||||
|
||||
export type GetFieldsForTokenOptions = {
|
||||
token: string;
|
||||
};
|
||||
@@ -31,10 +33,11 @@ export const getFieldsForToken = async ({ token }: GetFieldsForTokenOptions) =>
|
||||
signingStatus: {
|
||||
not: SigningStatus.SIGNED,
|
||||
},
|
||||
signingOrder: {
|
||||
gte: recipient.signingOrder ?? 0,
|
||||
},
|
||||
envelopeId: recipient.envelopeId,
|
||||
// Assistants can only assist those in strictly later steps —
|
||||
// never their own group peers, with null orders as the tail
|
||||
// step. (Own fields are matched by the sibling OR arm.)
|
||||
AND: [getLaterSigningStepRecipientsWhereInput(recipient)],
|
||||
},
|
||||
envelope: {
|
||||
id: recipient.envelopeId,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { assertRecipientNotExpired } from '@documenso/lib/utils/recipients';
|
||||
import { assertRecipientNotExpired, getFieldOwnerWhereInput } from '@documenso/lib/utils/recipients';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
|
||||
|
||||
@@ -25,21 +25,7 @@ export const removeSignedFieldWithToken = async ({
|
||||
const field = await prisma.field.findFirstOrThrow({
|
||||
where: {
|
||||
id: fieldId,
|
||||
recipient: {
|
||||
...(recipient.role !== RecipientRole.ASSISTANT
|
||||
? {
|
||||
id: recipient.id,
|
||||
}
|
||||
: {
|
||||
signingOrder: {
|
||||
gte: recipient.signingOrder ?? 0,
|
||||
},
|
||||
signingStatus: {
|
||||
not: SigningStatus.SIGNED,
|
||||
},
|
||||
envelopeId: recipient.envelopeId,
|
||||
}),
|
||||
},
|
||||
recipient: getFieldOwnerWhereInput(recipient),
|
||||
},
|
||||
include: {
|
||||
envelope: true,
|
||||
|
||||
@@ -13,6 +13,7 @@ import { match } from 'ts-pattern';
|
||||
import { AUTO_SIGNABLE_FIELD_TYPES } from '../../constants/autosign';
|
||||
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../../constants/date-formats';
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '../../constants/time-zones';
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||
import type { TRecipientActionAuth } from '../../types/document-auth';
|
||||
import {
|
||||
@@ -24,7 +25,7 @@ import {
|
||||
} from '../../types/field-meta';
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
import { assertRecipientNotExpired } from '../../utils/recipients';
|
||||
import { assertRecipientNotExpired, getFieldOwnerWhereInput } from '../../utils/recipients';
|
||||
import { validateFieldAuth } from '../document/validate-field-auth';
|
||||
|
||||
export type SignFieldWithTokenOptions = {
|
||||
@@ -65,21 +66,7 @@ export const signFieldWithToken = async ({
|
||||
const field = await prisma.field.findFirstOrThrow({
|
||||
where: {
|
||||
id: fieldId,
|
||||
recipient: {
|
||||
...(recipient.role !== RecipientRole.ASSISTANT
|
||||
? {
|
||||
id: recipient.id,
|
||||
}
|
||||
: {
|
||||
signingStatus: {
|
||||
not: SigningStatus.SIGNED,
|
||||
},
|
||||
signingOrder: {
|
||||
gte: recipient.signingOrder ?? 0,
|
||||
},
|
||||
envelopeId: recipient.envelopeId,
|
||||
}),
|
||||
},
|
||||
recipient: getFieldOwnerWhereInput(recipient),
|
||||
},
|
||||
include: {
|
||||
envelope: {
|
||||
@@ -124,6 +111,18 @@ export const signFieldWithToken = async ({
|
||||
throw new Error(`Field ${fieldId} has no recipientId`);
|
||||
}
|
||||
|
||||
// Mirrors the V2 guard in `sign-envelope-field.ts`: assistants may prefill
|
||||
// other recipients' fields but never their signature fields.
|
||||
if (
|
||||
field.type === FieldType.SIGNATURE &&
|
||||
recipient.role === RecipientRole.ASSISTANT &&
|
||||
field.recipientId !== recipient.id
|
||||
) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Assistant recipients cannot sign signature fields',
|
||||
});
|
||||
}
|
||||
|
||||
if (field.type === FieldType.NUMBER && field.fieldMeta) {
|
||||
const numberFieldParsedMeta = ZNumberFieldMeta.parse(field.fieldMeta);
|
||||
const errors = validateNumberField(value, numberFieldParsedMeta, true);
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { EnvelopeIdOptions } from '../../utils/envelope';
|
||||
import { mapRecipientToLegacyRecipient } from '../../utils/recipients';
|
||||
import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
import { assertCompatibleRecipientGrouping } from '../signature-level/assert-compatible-recipient-grouping';
|
||||
import { assertCompatibleRecipientRole } from '../signature-level/assert-compatible-recipient-role';
|
||||
|
||||
export interface CreateEnvelopeRecipientsOptions {
|
||||
@@ -91,6 +92,13 @@ export const createEnvelopeRecipients = async ({
|
||||
});
|
||||
}
|
||||
|
||||
// Grouping is a property of the whole recipient set, so check the state the
|
||||
// envelope will be left in rather than the incoming batch alone.
|
||||
assertCompatibleRecipientGrouping({
|
||||
signatureLevel: envelope.signatureLevel,
|
||||
recipients: [...envelope.recipients, ...recipientsToCreate],
|
||||
});
|
||||
|
||||
const normalizedRecipients = recipientsToCreate.map((recipient) => ({
|
||||
...recipient,
|
||||
email: recipient.email.toLowerCase(),
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentSigningOrder, EnvelopeType, RecipientRole, SigningStatus } from '@prisma/client';
|
||||
import { DocumentSigningOrder, EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { isRecipientTurnBySigningOrder } from '../../utils/recipient-groups';
|
||||
|
||||
export type GetIsRecipientTurnOptions = {
|
||||
token: string;
|
||||
@@ -17,11 +19,7 @@ export async function getIsRecipientsTurnToSign({ token }: GetIsRecipientTurnOpt
|
||||
},
|
||||
include: {
|
||||
documentMeta: true,
|
||||
recipients: {
|
||||
orderBy: {
|
||||
signingOrder: 'asc',
|
||||
},
|
||||
},
|
||||
recipients: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -29,24 +27,11 @@ export async function getIsRecipientsTurnToSign({ token }: GetIsRecipientTurnOpt
|
||||
return true;
|
||||
}
|
||||
|
||||
const { recipients } = envelope;
|
||||
const currentRecipient = envelope.recipients.find((recipient) => recipient.token === token);
|
||||
|
||||
const currentRecipientIndex = recipients.findIndex((r) => r.token === token);
|
||||
|
||||
if (currentRecipientIndex === -1) {
|
||||
if (!currentRecipient) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let i = 0; i < currentRecipientIndex; i++) {
|
||||
// CC recipients have no action to take, so they can never block the flow.
|
||||
if (recipients[i].role === RecipientRole.CC) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (recipients[i].signingStatus !== SigningStatus.SIGNED) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
return isRecipientTurnBySigningOrder(envelope.recipients, currentRecipient);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { EnvelopeType, RecipientRole } from '@prisma/client';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { mapDocumentIdToSecondaryId } from '../../utils/envelope';
|
||||
import { getNextDictatableRecipient } from '../../utils/recipient-groups';
|
||||
|
||||
export const getNextPendingRecipient = async ({
|
||||
documentId,
|
||||
@@ -16,33 +17,17 @@ export const getNextPendingRecipient = async ({
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
secondaryId: mapDocumentIdToSecondaryId(documentId),
|
||||
},
|
||||
// CC recipients are informational only and never take part in signing,
|
||||
// so they must never be offered as the next pending recipient.
|
||||
role: {
|
||||
not: RecipientRole.CC,
|
||||
},
|
||||
},
|
||||
orderBy: [
|
||||
{
|
||||
signingOrder: {
|
||||
sort: 'asc',
|
||||
nulls: 'last',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'asc',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const currentIndex = recipients.findIndex((r) => r.id === currentRecipientId);
|
||||
const nextRecipient = getNextDictatableRecipient({ recipients, currentRecipientId });
|
||||
|
||||
if (currentIndex === -1 || currentIndex === recipients.length - 1) {
|
||||
if (!nextRecipient) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...recipients[currentIndex + 1],
|
||||
...nextRecipient,
|
||||
token: '',
|
||||
};
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import { prisma } from '@documenso/prisma';
|
||||
import { FieldType } from '@prisma/client';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { getAssistableRecipientsWhereInput } from '../../utils/recipients';
|
||||
|
||||
export interface GetRecipientsForAssistantOptions {
|
||||
token: string;
|
||||
@@ -23,9 +24,9 @@ export const getRecipientsForAssistant = async ({ token }: GetRecipientsForAssis
|
||||
let recipients = await prisma.recipient.findMany({
|
||||
where: {
|
||||
envelopeId: assistant.envelopeId,
|
||||
signingOrder: {
|
||||
gte: assistant.signingOrder ?? 0,
|
||||
},
|
||||
// The assistant themself plus strictly later steps — never their own
|
||||
// group peers, with null orders treated as the tail step.
|
||||
AND: [getAssistableRecipientsWhereInput(assistant)],
|
||||
},
|
||||
include: {
|
||||
fields: {
|
||||
|
||||
@@ -17,6 +17,7 @@ import { type EnvelopeIdOptions, mapSecondaryIdToDocumentId } from '../../utils/
|
||||
import { canRecipientBeModified, isRecipientEmailValidForSending } from '../../utils/recipients';
|
||||
import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
import { assertCompatibleRecipientGrouping } from '../signature-level/assert-compatible-recipient-grouping';
|
||||
import { assertCompatibleRecipientRole } from '../signature-level/assert-compatible-recipient-role';
|
||||
|
||||
export interface SetDocumentRecipientsOptions {
|
||||
@@ -98,6 +99,13 @@ export const setDocumentRecipients = async ({
|
||||
});
|
||||
}
|
||||
|
||||
// This route replaces the whole recipient set, so the payload is the
|
||||
// resulting state.
|
||||
assertCompatibleRecipientGrouping({
|
||||
signatureLevel: envelope.signatureLevel,
|
||||
recipients,
|
||||
});
|
||||
|
||||
const normalizedRecipients = recipients.map((recipient) => ({
|
||||
...recipient,
|
||||
email: recipient.email.toLowerCase(),
|
||||
|
||||
@@ -12,6 +12,7 @@ import { nanoid } from '../../universal/id';
|
||||
import { createRecipientAuthOptions } from '../../utils/document-auth';
|
||||
import { type EnvelopeIdOptions, mapSecondaryIdToTemplateId } from '../../utils/envelope';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
import { assertCompatibleRecipientGrouping } from '../signature-level/assert-compatible-recipient-grouping';
|
||||
import { assertCompatibleRecipientRole } from '../signature-level/assert-compatible-recipient-role';
|
||||
|
||||
export type SetTemplateRecipientsOptions = {
|
||||
@@ -68,6 +69,13 @@ export const setTemplateRecipients = async ({ userId, teamId, id, recipients }:
|
||||
});
|
||||
}
|
||||
|
||||
// This route replaces the whole recipient set, so the payload is the
|
||||
// resulting state.
|
||||
assertCompatibleRecipientGrouping({
|
||||
signatureLevel: envelope.signatureLevel,
|
||||
recipients,
|
||||
});
|
||||
|
||||
const normalizedRecipients = recipients.map((recipient) => {
|
||||
// Force replace any changes to the name or email of the direct recipient.
|
||||
if (envelope.directLink && recipient.id === envelope.directLink.directTemplateRecipientId) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { mapFieldToLegacyField } from '../../utils/fields';
|
||||
import { canRecipientBeModified } from '../../utils/recipients';
|
||||
import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
import { assertCompatibleRecipientGrouping } from '../signature-level/assert-compatible-recipient-grouping';
|
||||
import { assertCompatibleRecipientRole } from '../signature-level/assert-compatible-recipient-role';
|
||||
|
||||
export interface UpdateEnvelopeRecipientsOptions {
|
||||
@@ -99,6 +100,17 @@ export const updateEnvelopeRecipients = async ({
|
||||
});
|
||||
}
|
||||
|
||||
// Grouping is a property of the whole recipient set, so check the state the
|
||||
// envelope will be left in once these updates are applied.
|
||||
assertCompatibleRecipientGrouping({
|
||||
signatureLevel: envelope.signatureLevel,
|
||||
recipients: envelope.recipients.map((existingRecipient) => {
|
||||
const update = recipients.find((recipient) => recipient.id === existingRecipient.id);
|
||||
|
||||
return update ? { ...existingRecipient, ...update } : existingRecipient;
|
||||
}),
|
||||
});
|
||||
|
||||
const recipientsToUpdate = recipients.map((recipient) => {
|
||||
const originalRecipient = envelope.recipients.find((existingRecipient) => existingRecipient.id === recipient.id);
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { RecipientRole } from '@prisma/client';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { SignatureLevel } from '../../types/signature-level';
|
||||
import { assertCompatibleRecipientGrouping } from './assert-compatible-recipient-grouping';
|
||||
|
||||
const signer = (signingOrder: number | null) => ({ role: RecipientRole.SIGNER, signingOrder });
|
||||
const cc = (signingOrder: number | null) => ({ role: RecipientRole.CC, signingOrder });
|
||||
|
||||
const expectRejected = (
|
||||
signatureLevel: string,
|
||||
recipients: Array<{ role: RecipientRole; signingOrder: number | null }>,
|
||||
) => {
|
||||
expect(() => assertCompatibleRecipientGrouping({ signatureLevel, recipients })).toThrow(
|
||||
/signing group|same signing step/i,
|
||||
);
|
||||
};
|
||||
|
||||
const expectAccepted = (
|
||||
signatureLevel: string,
|
||||
recipients: Array<{ role: RecipientRole; signingOrder: number | null }>,
|
||||
) => {
|
||||
expect(() => assertCompatibleRecipientGrouping({ signatureLevel, recipients })).not.toThrow();
|
||||
};
|
||||
|
||||
describe('assertCompatibleRecipientGrouping', () => {
|
||||
describe('AES/QES envelopes', () => {
|
||||
for (const signatureLevel of [SignatureLevel.AES, SignatureLevel.QES]) {
|
||||
it(`rejects two signers sharing a signing order (${signatureLevel})`, () => {
|
||||
expectRejected(signatureLevel, [signer(1), signer(2), signer(2)]);
|
||||
});
|
||||
|
||||
it(`accepts distinct signing orders (${signatureLevel})`, () => {
|
||||
expectAccepted(signatureLevel, [signer(1), signer(2), signer(3)]);
|
||||
});
|
||||
}
|
||||
|
||||
// Every null order collapses into the same tail step, so two of them sign
|
||||
// in parallel exactly as a duplicate order would.
|
||||
it('rejects two signers without a signing order', () => {
|
||||
expectRejected(SignatureLevel.AES, [signer(null), signer(null)]);
|
||||
});
|
||||
|
||||
it('rejects a signer without an order alongside an ordered signer', () => {
|
||||
// The unordered recipient shares the tail step with the other null.
|
||||
expectRejected(SignatureLevel.AES, [signer(1), signer(null), signer(null)]);
|
||||
});
|
||||
|
||||
it('accepts a single signer without a signing order', () => {
|
||||
expectAccepted(SignatureLevel.AES, [signer(null)]);
|
||||
});
|
||||
|
||||
it('accepts one ordered signer and one unordered signer', () => {
|
||||
expectAccepted(SignatureLevel.AES, [signer(1), signer(null)]);
|
||||
});
|
||||
|
||||
// CC recipients never sign, so their order carries no meaning.
|
||||
it('ignores CC recipients sharing an order with a signer', () => {
|
||||
expectAccepted(SignatureLevel.AES, [signer(1), cc(1)]);
|
||||
});
|
||||
|
||||
it('ignores several CC recipients sharing an order with each other', () => {
|
||||
expectAccepted(SignatureLevel.AES, [signer(1), cc(2), cc(2), cc(null), cc(null)]);
|
||||
});
|
||||
|
||||
it('accepts an empty recipient list', () => {
|
||||
expectAccepted(SignatureLevel.AES, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SES envelopes', () => {
|
||||
it('permits signing groups', () => {
|
||||
expectAccepted(SignatureLevel.SES, [signer(1), signer(2), signer(2)]);
|
||||
});
|
||||
|
||||
it('permits multiple unordered signers', () => {
|
||||
expectAccepted(SignatureLevel.SES, [signer(null), signer(null)]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { Recipient } from '@prisma/client';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { isTspEnvelope } from '../../types/signature-level';
|
||||
import { effectiveOrder } from '../../utils/recipient-groups';
|
||||
import { isCcRecipient } from '../../utils/recipients';
|
||||
|
||||
type AssertCompatibleRecipientGroupingOptions = {
|
||||
signatureLevel: string;
|
||||
recipients: Array<Pick<Recipient, 'role'> & { signingOrder?: number | null }>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reject recipient signing groups on AES/QES envelopes.
|
||||
*
|
||||
* A "group" is two or more signing recipients sharing a signing step, which
|
||||
* they may then complete in any order — including at the same time. That is
|
||||
* parallel signing scoped to one step, so it breaks the same per-recipient
|
||||
* `/ByteRange` invariant that {@link assertCompatibleSigningOrder} exists to
|
||||
* protect: each TSP signature must cover the exact bytes it was applied to,
|
||||
* and concurrent incremental updates over one base state cannot all verify.
|
||||
*
|
||||
* Note this is not caught by the `signingOrder = PARALLEL` guard: groups are
|
||||
* expressed as duplicate `Recipient.signingOrder` values on a document whose
|
||||
* `documentMeta.signingOrder` is SEQUENTIAL.
|
||||
*
|
||||
* Recipients sharing a step are detected by {@link effectiveOrder}, so an
|
||||
* absent signing order counts too — every unordered recipient lands in the
|
||||
* same tail step and would sign in parallel.
|
||||
*
|
||||
* CC recipients are ignored: they never sign, and their signing order carries
|
||||
* no meaning anywhere else.
|
||||
*
|
||||
* SES envelopes pass through unchanged — signing groups are an SES feature.
|
||||
*
|
||||
* Schema-layer guard. {@link sendDocument} re-checks at distribution time as a
|
||||
* defence-in-depth backstop.
|
||||
*/
|
||||
export const assertCompatibleRecipientGrouping = ({
|
||||
signatureLevel,
|
||||
recipients,
|
||||
}: AssertCompatibleRecipientGroupingOptions): void => {
|
||||
if (!isTspEnvelope({ signatureLevel })) {
|
||||
return;
|
||||
}
|
||||
|
||||
const seenOrders = new Set<number>();
|
||||
|
||||
for (const recipient of recipients) {
|
||||
if (isCcRecipient(recipient)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const order = effectiveOrder(recipient);
|
||||
|
||||
if (seenOrders.has(order)) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: `Envelopes signed at '${signatureLevel}' cannot place two recipients in the same signing step — a signing group is parallel signing within one step, which breaks the per-recipient /ByteRange invariant TSP signatures rely on. Give every signing recipient a distinct signingOrder.`,
|
||||
});
|
||||
}
|
||||
|
||||
seenOrders.add(order);
|
||||
}
|
||||
};
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
import { RecipientRole } from '@prisma/client';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { assertCompatibleRecipientGrouping } from './assert-compatible-recipient-grouping';
|
||||
import { assignDefaultRecipientSigningOrders } from './assign-default-recipient-signing-orders';
|
||||
|
||||
const signer = (signingOrder?: number | null) => ({
|
||||
role: RecipientRole.SIGNER,
|
||||
signingOrder,
|
||||
});
|
||||
|
||||
const defaultRecipient = (email: string, role: RecipientRole = RecipientRole.SIGNER) => ({
|
||||
email,
|
||||
name: email,
|
||||
role,
|
||||
});
|
||||
|
||||
describe('assignDefaultRecipientSigningOrders', () => {
|
||||
it('leaves defaults unordered on SES envelopes', () => {
|
||||
const result = assignDefaultRecipientSigningOrders({
|
||||
signatureLevel: 'SES',
|
||||
payloadRecipients: [signer(1), signer(2)],
|
||||
defaultRecipients: [defaultRecipient('a@example.com'), defaultRecipient('b@example.com')],
|
||||
});
|
||||
|
||||
expect(result.map((recipient) => recipient.signingOrder)).toEqual([undefined, undefined]);
|
||||
});
|
||||
|
||||
it.each(['AES', 'QES'])('assigns distinct orders after the payload max on %s envelopes', (signatureLevel) => {
|
||||
const result = assignDefaultRecipientSigningOrders({
|
||||
signatureLevel,
|
||||
payloadRecipients: [signer(1), signer(4)],
|
||||
defaultRecipients: [defaultRecipient('a@example.com'), defaultRecipient('b@example.com')],
|
||||
});
|
||||
|
||||
expect(result.map((recipient) => recipient.signingOrder)).toEqual([5, 6]);
|
||||
});
|
||||
|
||||
it('numbers defaults from 1 when the payload has no numeric orders', () => {
|
||||
const result = assignDefaultRecipientSigningOrders({
|
||||
signatureLevel: 'QES',
|
||||
payloadRecipients: [],
|
||||
defaultRecipients: [defaultRecipient('a@example.com'), defaultRecipient('b@example.com')],
|
||||
});
|
||||
|
||||
expect(result.map((recipient) => recipient.signingOrder)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('skips CC defaults while numbering the rest', () => {
|
||||
const result = assignDefaultRecipientSigningOrders({
|
||||
signatureLevel: 'AES',
|
||||
payloadRecipients: [signer(2)],
|
||||
defaultRecipients: [
|
||||
defaultRecipient('a@example.com'),
|
||||
defaultRecipient('cc@example.com', RecipientRole.CC),
|
||||
defaultRecipient('b@example.com'),
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.map((recipient) => recipient.signingOrder)).toEqual([3, undefined, 4]);
|
||||
});
|
||||
|
||||
it('produces a combined set that satisfies the TSP grouping assertion', () => {
|
||||
const payloadRecipients = [signer(1), signer(2)];
|
||||
|
||||
const defaults = assignDefaultRecipientSigningOrders({
|
||||
signatureLevel: 'QES',
|
||||
payloadRecipients,
|
||||
defaultRecipients: [defaultRecipient('a@example.com'), defaultRecipient('b@example.com')],
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
assertCompatibleRecipientGrouping({
|
||||
signatureLevel: 'QES',
|
||||
recipients: [...payloadRecipients, ...defaults],
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('remains assertion-compatible when the payload holds a single unordered recipient', () => {
|
||||
// The write-path assert permits one unordered recipient (no shared step);
|
||||
// numbered defaults must not collide with it.
|
||||
const payloadRecipients = [signer(null)];
|
||||
|
||||
const defaults = assignDefaultRecipientSigningOrders({
|
||||
signatureLevel: 'AES',
|
||||
payloadRecipients,
|
||||
defaultRecipients: [defaultRecipient('a@example.com')],
|
||||
});
|
||||
|
||||
expect(defaults.map((recipient) => recipient.signingOrder)).toEqual([1]);
|
||||
|
||||
expect(() =>
|
||||
assertCompatibleRecipientGrouping({
|
||||
signatureLevel: 'AES',
|
||||
recipients: [...payloadRecipients, ...defaults],
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { Recipient } from '@prisma/client';
|
||||
|
||||
import { isTspEnvelope } from '../../types/signature-level';
|
||||
import { isCcRecipient } from '../../utils/recipients';
|
||||
|
||||
type AssignDefaultRecipientSigningOrdersOptions<T> = {
|
||||
signatureLevel: string;
|
||||
/**
|
||||
* The recipients supplied by the caller, already validated by
|
||||
* {@link assertCompatibleRecipientGrouping}.
|
||||
*/
|
||||
payloadRecipients: Array<Pick<Recipient, 'role'> & { signingOrder?: number | null }>;
|
||||
/**
|
||||
* The team default recipients to append.
|
||||
*/
|
||||
defaultRecipients: T[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Assigns distinct signing orders to team default recipients appended to a
|
||||
* TSP (AES/QES) envelope.
|
||||
*
|
||||
* Team defaults carry no signing order, so on a TSP envelope two or more of
|
||||
* them would share the unordered tail step — a signing group, which TSP
|
||||
* signatures cannot hold (see {@link assertCompatibleRecipientGrouping}).
|
||||
* Numbering them after the payload's highest order keeps the combined set
|
||||
* valid at write time instead of relying on the send-time flatten backstop.
|
||||
*
|
||||
* CC defaults are left unordered: they never sign and are ignored by the
|
||||
* grouping assertion.
|
||||
*
|
||||
* SES envelopes pass through unchanged — shared steps are an SES feature.
|
||||
*/
|
||||
export const assignDefaultRecipientSigningOrders = <T extends Pick<Recipient, 'role'>>({
|
||||
signatureLevel,
|
||||
payloadRecipients,
|
||||
defaultRecipients,
|
||||
}: AssignDefaultRecipientSigningOrdersOptions<T>): Array<T & { signingOrder?: number }> => {
|
||||
if (!isTspEnvelope({ signatureLevel })) {
|
||||
return defaultRecipients;
|
||||
}
|
||||
|
||||
let nextOrder =
|
||||
payloadRecipients.reduce((highest, recipient) => Math.max(highest, recipient.signingOrder ?? 0), 0) + 1;
|
||||
|
||||
return defaultRecipients.map((recipient) => {
|
||||
if (isCcRecipient(recipient)) {
|
||||
return recipient;
|
||||
}
|
||||
|
||||
const signingOrder = nextOrder;
|
||||
|
||||
nextOrder += 1;
|
||||
|
||||
return { ...recipient, signingOrder };
|
||||
});
|
||||
};
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
extractDocumentAuthMethods,
|
||||
} from '../../utils/document-auth';
|
||||
import { mapSecondaryIdToTemplateId } from '../../utils/envelope';
|
||||
import { filterRecipientsInFirstSigningGroup } from '../../utils/recipient-groups';
|
||||
import { getRecipientsWithMissingFields } from '../../utils/recipients';
|
||||
import { sendDocument } from '../document/send-document';
|
||||
import { validateFieldAuth } from '../document/validate-field-auth';
|
||||
@@ -676,6 +677,7 @@ export const createDocumentFromDirectTemplate = async ({
|
||||
select: {
|
||||
id: true,
|
||||
signingOrder: true,
|
||||
signingStatus: true,
|
||||
name: true,
|
||||
email: true,
|
||||
role: true,
|
||||
@@ -694,9 +696,27 @@ export const createDocumentFromDirectTemplate = async ({
|
||||
orderBy: [{ signingOrder: { sort: 'asc', nulls: 'last' } }, { id: 'asc' }],
|
||||
});
|
||||
|
||||
const nextRecipient = pendingRecipients[0];
|
||||
const nextGroup = filterRecipientsInFirstSigningGroup(pendingRecipients);
|
||||
|
||||
if (nextRecipient) {
|
||||
const directRecipientOrder = createdDirectRecipient.signingOrder ?? Number.MAX_SAFE_INTEGER;
|
||||
|
||||
// The direct recipient can share a step with other recipients (a signing
|
||||
// group). Those peers are still pending, so without this check they would
|
||||
// look like the "next" step and be dictated over — dictation may only
|
||||
// affect a strictly later step.
|
||||
const hasCompletedCurrentStep = nextGroup.every(
|
||||
(pendingRecipient) => (pendingRecipient.signingOrder ?? Number.MAX_SAFE_INTEGER) > directRecipientOrder,
|
||||
);
|
||||
|
||||
// Dictation only applies when the next step is a single recipient.
|
||||
const nextRecipient = hasCompletedCurrentStep && nextGroup.length === 1 ? nextGroup[0] : null;
|
||||
|
||||
// The guard at the top of this function rejects `nextSigner` unless the
|
||||
// template enables dictation, and the derived meta carries the flag
|
||||
// through unchanged — but the audit log and the update must share ONE
|
||||
// condition regardless, so the trail can never record a rewrite that
|
||||
// was not applied.
|
||||
if (nextRecipient && documentMeta.allowDictateNextSigner) {
|
||||
auditLogsToCreate.push(
|
||||
createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
|
||||
@@ -730,12 +750,8 @@ export const createDocumentFromDirectTemplate = async ({
|
||||
await tx.recipient.update({
|
||||
where: { id: nextRecipient.id },
|
||||
data: {
|
||||
...(nextSigner && documentMeta?.allowDictateNextSigner
|
||||
? {
|
||||
name: nextSigner.name,
|
||||
email: nextSigner.email,
|
||||
}
|
||||
: {}),
|
||||
name: nextSigner.name,
|
||||
email: nextSigner.email,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -125,3 +125,21 @@ export type TEnvelopeRecipientLite = z.infer<typeof ZEnvelopeRecipientLiteSchema
|
||||
export type TEnvelopeRecipientMany = z.infer<typeof ZEnvelopeRecipientManySchema>;
|
||||
|
||||
export const ZRecipientEmailSchema = z.union([z.literal(''), zEmail('Invalid email').trim().toLowerCase().max(254)]);
|
||||
|
||||
/**
|
||||
* Signing order for a recipient, for use in request schemas.
|
||||
*
|
||||
* `Recipient.signingOrder` is an `Int` column and Prisma truncates rather than
|
||||
* rejects a fraction, so an unconstrained `z.number()` silently rewrites the
|
||||
* caller's value (1.5 becomes 1) — which, since equal orders mean "same signing
|
||||
* step", can quietly merge recipients into one group. Orders are also 1-based
|
||||
* everywhere they are generated, and assistant scoping uses `?? 0` as its
|
||||
* floor, so zero and negatives are not meaningful.
|
||||
*
|
||||
* Response schemas intentionally do not use this: existing rows may hold values
|
||||
* that predate the constraint, and reads must not fail because of it.
|
||||
*/
|
||||
export const ZRecipientSigningOrderSchema = z
|
||||
.number()
|
||||
.int('Signing order must be an integer')
|
||||
.min(1, 'Signing order must be greater than 0');
|
||||
|
||||
@@ -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