This commit is contained in:
David Nguyen
2026-08-26 12:10:29 +10:00
parent 9dc83bdb06
commit 6db71b13d4
70 changed files with 5964 additions and 892 deletions
+125 -5
View File
@@ -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.
*