fix: reviewed

This commit is contained in:
David Nguyen
2026-08-27 09:45:38 +10:00
parent 25671669b7
commit 06cc701d8c
5 changed files with 72 additions and 54 deletions
@@ -38,9 +38,11 @@ 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 { getRecipientsInActiveSigningStep } 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';
import { assertCompatibleRecipientGrouping } from '../signature-level/assert-compatible-recipient-grouping';
import { assertUserNotDisabledById } from '../user/assert-user-not-disabled';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
@@ -147,13 +149,17 @@ export const sendDocument = async ({ id, userId, teamId, sendEmail, requestMetad
envelope.documentMeta.signingOrder = DocumentSigningOrder.SEQUENTIAL;
}
assertCompatibleRecipientGrouping({
signatureLevel: envelope.signatureLevel,
recipients: envelope.recipients,
});
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 = getRecipientsInActiveSigningStep(envelope.recipients);
}
if (envelope.envelopeItems.length === 0) {
@@ -0,0 +1,54 @@
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
*
* 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.
*/
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);
}
};
@@ -3,7 +3,6 @@ import { describe, expect, it } from 'vitest';
import {
extractRecipientToNewStep,
flattenRecipientGroups,
getNextDictatableRecipient,
getRecipientsInActiveSigningStep,
groupRecipientsBySigningOrder,
@@ -450,51 +449,6 @@ describe('locked step guards', () => {
});
});
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,
@@ -1,5 +1,9 @@
import { ZRecipientAccessAuthTypesSchema, ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-auth';
import { ZRecipientEmailSchema, ZRecipientLiteSchema } from '@documenso/lib/types/recipient';
import {
ZRecipientEmailSchema,
ZRecipientLiteSchema,
ZRecipientSigningOrderSchema,
} from '@documenso/lib/types/recipient';
import { RecipientRole } from '@prisma/client';
import { z } from 'zod';
@@ -20,7 +24,7 @@ export const ZUpdateEnvelopeRecipientSchema = z.object({
email: ZRecipientEmailSchema.optional(),
name: z.string().max(255).optional(),
role: z.nativeEnum(RecipientRole).optional(),
signingOrder: z.number().optional(),
signingOrder: ZRecipientSigningOrderSchema.optional(),
accessAuth: z.array(ZRecipientAccessAuthTypesSchema).default([]).optional(),
actionAuth: z.array(ZRecipientActionAuthTypesSchema).default([]).optional(),
});
@@ -15,7 +15,7 @@ import {
} from '@documenso/lib/types/document-meta';
import { ZEnvelopeAttachmentTypeSchema } from '@documenso/lib/types/envelope-attachment';
import { ZFieldMetaPrefillFieldsSchema } from '@documenso/lib/types/field-meta';
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
import { ZRecipientEmailSchema, ZRecipientSigningOrderSchema } from '@documenso/lib/types/recipient';
import { z } from 'zod';
import { zfd } from 'zod-form-data';
@@ -44,7 +44,7 @@ export const ZUseEnvelopePayloadSchema = z.object({
id: z.number().describe('The ID of the recipient in the template.'),
email: ZRecipientEmailSchema,
name: z.string().max(255).optional(),
signingOrder: z.number().optional(),
signingOrder: ZRecipientSigningOrderSchema.optional(),
}),
)
.describe('The information of the recipients to create the document with.')