Files
documenso/packages/lib/utils/recipients.ts
T

304 lines
9.4 KiB
TypeScript

import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
import type { Envelope, Field, 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';
/**
* Roles that require fields to be assigned before a document can be distributed.
*
* Currently only SIGNER requires a signature field.
*/
export const RECIPIENT_ROLES_THAT_REQUIRE_FIELDS = [RecipientRole.SIGNER] as const;
// signingOrder isn't required when submitting the recipient form (Zod: z.number().optional())
type RecipientWithSigningOrder = Pick<Recipient, 'role'> & PositionedRecipient;
export const isCcRecipient = (recipient: Pick<Recipient, 'role'>) => {
return recipient.role === RecipientRole.CC;
};
/**
* Recipients sharing an explicit signing order form a step and may act in
* parallel. A recipient without one (legacy rows predating automatic
* numbering) never shares a step: unordered recipients sort after every
* numbered recipient and among themselves by id, matching how the server has
* always processed them (`ORDER BY signingOrder NULLS LAST, id`).
*/
export type PositionedRecipient = {
id?: number | null;
signingOrder?: number | null;
};
export const hasSigningOrder = (recipient: PositionedRecipient): recipient is { signingOrder: number } =>
typeof recipient.signingOrder === 'number';
const hasPersistedId = (recipient: PositionedRecipient): recipient is { id: number } =>
typeof recipient.id === 'number';
/**
* Unsaved (id-less) unordered recipients sort last, in input order.
*/
export const compareRecipientSigningPosition = (a: PositionedRecipient, b: PositionedRecipient): number => {
const aIsNumbered = hasSigningOrder(a);
const bIsNumbered = hasSigningOrder(b);
if (aIsNumbered && bIsNumbered) {
return a.signingOrder - b.signingOrder;
}
if (aIsNumbered !== bIsNumbered) {
return aIsNumbered ? -1 : 1;
}
const aHasId = hasPersistedId(a);
const bHasId = hasPersistedId(b);
if (aHasId && bHasId) {
return a.id - b.id;
}
if (aHasId !== bHasId) {
return aHasId ? -1 : 1;
}
return 0;
};
export const sortRecipientsBySigningPosition = <T extends PositionedRecipient>(recipients: T[]): T[] =>
[...recipients].sort(compareRecipientSigningPosition);
export const isSameSigningStep = (a: PositionedRecipient, b: PositionedRecipient): boolean =>
hasSigningOrder(a) && hasSigningOrder(b) && a.signingOrder === b.signingOrder;
/**
* Whether `recipient` must act before `other`.
*
* `strictlySequential` also orders group members by id so no two recipients
* are ever eligible at once — required on AES/QES, where a TSP signature is
* computed over a document snapshot and overlapping signers would invalidate
* each other's /ByteRange.
*/
export const isRecipientBefore = (
recipient: PositionedRecipient,
other: PositionedRecipient,
options: { strictlySequential?: boolean } = {},
): boolean => {
const comparison = compareRecipientSigningPosition(recipient, other);
if (comparison !== 0) {
return comparison < 0;
}
if (!options.strictlySequential || !isSameSigningStep(recipient, other)) {
return false;
}
return hasPersistedId(recipient) && hasPersistedId(other) && recipient.id < other.id;
};
/**
* Whether an assistant sits in the last signing step (nobody after them to assist).
*/
export const isAssistantLastSigner = (recipients: RecipientWithSigningOrder[]) => {
const nonCcRecipients = sortRecipientsBySigningPosition(recipients.filter((recipient) => !isCcRecipient(recipient)));
const lastRecipient = nonCcRecipients[nonCcRecipients.length - 1];
if (!lastRecipient) {
return false;
}
return nonCcRecipients.some(
(recipient) =>
recipient.role === RecipientRole.ASSISTANT &&
(recipient === lastRecipient || isSameSigningStep(recipient, lastRecipient)),
);
};
export const sortRecipientsForSigningOrder = <T extends RecipientWithSigningOrder>(recipients: T[]): T[] => {
return [...recipients].sort((r1, r2) => {
const r1IsCcRecipient = isCcRecipient(r1);
const r2IsCcRecipient = isCcRecipient(r2);
// CC recipients always sort after non-CC recipients.
if (r1IsCcRecipient !== r2IsCcRecipient) {
return r1IsCcRecipient ? 1 : -1;
}
return compareRecipientSigningPosition(r1, r2);
});
};
export const normalizeRecipientSigningOrders = <T extends RecipientWithSigningOrder>(
recipients: T[],
canUpdateRecipient: (recipient: T) => boolean = () => true,
): Array<T & { signingOrder?: number }> => {
const nonCcRecipients = recipients.filter((recipient) => !isCcRecipient(recipient));
const ccRecipients = recipients.filter((recipient) => isCcRecipient(recipient));
const normalizedNonCcRecipients = nonCcRecipients.map((recipient, index) => ({
...recipient,
signingOrder: canUpdateRecipient(recipient) ? index + 1 : (recipient.signingOrder ?? index + 1),
}));
const normalizedCcRecipients = ccRecipients.map((recipient) => ({
...recipient,
signingOrder: undefined,
}));
return [...normalizedNonCcRecipients, ...normalizedCcRecipients];
};
/**
* Returns recipients who are missing required fields for their role.
*
* Currently only SIGNERs are validated - they must have at least one signature field.
*/
export const getRecipientsWithMissingFields = <T extends Pick<TRecipientLite, 'id' | 'role'>>(
recipients: T[],
fields: Pick<Field, 'type' | 'recipientId'>[],
): T[] => {
return recipients.filter((recipient) => {
if (recipient.role === RecipientRole.SIGNER) {
const hasSignatureField = fields.some(
(field) => field.recipientId === recipient.id && isSignatureFieldType(field.type),
);
return !hasSignatureField;
}
return false;
});
};
export const formatSigningLink = (token: string) => `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${token}`;
/**
* Whether a recipient can be modified by the document owner.
*/
export const canRecipientBeModified = (
recipient: TRecipientLite,
fields: Pick<Field, 'recipientId' | 'inserted'>[],
) => {
if (!recipient) {
return false;
}
// CCers can always be modified (unless document is completed).
if (recipient.role === RecipientRole.CC) {
return true;
}
// Deny if the recipient has already signed the document.
if (recipient.signingStatus === SigningStatus.SIGNED) {
return false;
}
// Deny if the recipient has inserted any fields.
if (fields.some((field) => field.recipientId === recipient.id && field.inserted)) {
return false;
}
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.
*
* A recipient can their fields modified if all the conditions are met:
* - They are not a Viewer or CCer
* - They can be modified (canRecipientBeModified)
*/
export const canRecipientFieldsBeModified = (
recipient: TRecipientLite,
fields: Pick<Field, 'recipientId' | 'inserted'>[],
) => {
if (!canRecipientBeModified(recipient, fields)) {
return false;
}
return recipient.role !== RecipientRole.VIEWER && recipient.role !== RecipientRole.CC;
};
export const mapRecipientToLegacyRecipient = (
recipient: TRecipientLite,
envelope: Pick<Envelope, 'type' | 'secondaryId'>,
) => {
const legacyId = extractLegacyIds(envelope);
return {
...recipient,
...legacyId,
};
};
export const findRecipientByEmail = <T extends { email: string }>({
recipients,
userEmail,
teamEmail,
}: {
recipients: T[];
userEmail: string;
teamEmail?: string | null;
}) => recipients.find((r) => r.email === userEmail || (teamEmail && r.email === teamEmail));
export const isRecipientEmailValidForSending = (recipient: Pick<TRecipientLite, 'email'>) => {
return zEmail().safeParse(recipient.email).success;
};
/**
* Whether the recipient's signing window has expired.
*/
export const isRecipientExpired = (recipient: { expiresAt: Date | null }) => {
return Boolean(recipient.expiresAt && new Date(recipient.expiresAt) <= new Date());
};
/**
* Asserts that the recipient's signing window has not expired.
*
* Throws an AppError with RECIPIENT_EXPIRED if the expiration date has passed.
*/
export const assertRecipientNotExpired = (recipient: { expiresAt: Date | null }) => {
if (isRecipientExpired(recipient)) {
throw new AppError(AppErrorCode.RECIPIENT_EXPIRED, {
message: 'Recipient signing window has expired',
});
}
};