documenso/packages/lib/utils/envelope.ts
2026-04-14 21:01:53 +10:00

300 lines
7.9 KiB
TypeScript

import type { Envelope, Recipient } from '@prisma/client';
import {
DocumentStatus,
EnvelopeType,
RecipientRole,
SendStatus,
SigningStatus,
} from '@prisma/client';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { AppError, AppErrorCode } from '../errors/app-error';
const envelopeDocumentPrefixId = 'document';
const envelopeTemplatePrefixId = 'template';
const ZDocumentIdSchema = z.string().regex(/^document_\d+$/);
const ZTemplateIdSchema = z.string().regex(/^template_\d+$/);
const ZEnvelopeIdSchema = z.string().regex(/^envelope_.{2,}$/);
const MAX_ENVELOPE_IDS_PER_REQUEST = 20;
export type EnvelopeIdOptions =
| {
type: 'envelopeId';
id: string;
}
| {
type: 'documentId';
id: number;
}
| {
type: 'templateId';
id: number;
};
export type EnvelopeIdsOptions =
| {
type: 'envelopeId';
ids: string[];
}
| {
type: 'documentId';
ids: number[];
}
| {
type: 'templateId';
ids: number[];
};
/**
* Parses an unknown document or template ID.
*
* This is UNSAFE because does not validate access, it just builds the query for ID and TYPE.
*/
export const unsafeBuildEnvelopeIdQuery = (
options: EnvelopeIdOptions,
expectedEnvelopeType: EnvelopeType | null,
) => {
return match(options)
.with({ type: 'envelopeId' }, (value) => {
const parsed = ZEnvelopeIdSchema.safeParse(value.id);
if (!parsed.success) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Invalid envelope ID',
});
}
if (expectedEnvelopeType) {
return {
id: value.id,
type: expectedEnvelopeType,
};
}
return {
id: value.id,
};
})
.with({ type: 'documentId' }, (value) => {
if (expectedEnvelopeType && expectedEnvelopeType !== EnvelopeType.DOCUMENT) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Invalid document ID',
});
}
return {
type: EnvelopeType.DOCUMENT,
secondaryId: mapDocumentIdToSecondaryId(value.id),
};
})
.with({ type: 'templateId' }, (value) => {
if (expectedEnvelopeType && expectedEnvelopeType !== EnvelopeType.TEMPLATE) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Invalid template ID',
});
}
return {
type: EnvelopeType.TEMPLATE,
secondaryId: mapTemplateIdToSecondaryId(value.id),
};
})
.exhaustive();
};
/**
* Parses multiple document or template IDs and builds a query filter.
*
* This is UNSAFE because it does not validate access, it only validates ID format and builds the query.
*
* @throws AppError if any ID is invalid or if the array exceeds the maximum limit
*/
export const unsafeBuildEnvelopeIdsQuery = (
options: EnvelopeIdsOptions,
expectedEnvelopeType: EnvelopeType | null,
) => {
if (!options.ids || options.ids.length === 0) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'At least one ID is required',
});
}
if (options.ids.length > MAX_ENVELOPE_IDS_PER_REQUEST) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Cannot request more than ${MAX_ENVELOPE_IDS_PER_REQUEST} envelopes at once`,
});
}
return match(options)
.with({ type: 'envelopeId' }, (value) => {
const validatedIds: string[] = [];
for (const id of value.ids) {
const parsed = ZEnvelopeIdSchema.safeParse(id);
if (!parsed.success) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Invalid envelope ID: ${id}`,
});
}
validatedIds.push(parsed.data);
}
if (expectedEnvelopeType) {
return {
id: { in: validatedIds },
type: expectedEnvelopeType,
};
}
return {
id: { in: validatedIds },
};
})
.with({ type: 'documentId' }, (value) => {
if (expectedEnvelopeType && expectedEnvelopeType !== EnvelopeType.DOCUMENT) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Invalid document ID type',
});
}
const secondaryIds = value.ids.map((id) => mapDocumentIdToSecondaryId(id));
return {
type: EnvelopeType.DOCUMENT,
secondaryId: { in: secondaryIds },
};
})
.with({ type: 'templateId' }, (value) => {
if (expectedEnvelopeType && expectedEnvelopeType !== EnvelopeType.TEMPLATE) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Invalid template ID type',
});
}
const secondaryIds = value.ids.map((id) => mapTemplateIdToSecondaryId(id));
return {
type: EnvelopeType.TEMPLATE,
secondaryId: { in: secondaryIds },
};
})
.exhaustive();
};
/**
* Maps a legacy document ID number to an envelope secondary ID.
*
* @returns The formatted envelope secondary ID (document_123)
*/
export const mapDocumentIdToSecondaryId = (documentId: number) => {
return `${envelopeDocumentPrefixId}_${documentId}`;
};
/**
* Maps a legacy template ID number to an envelope secondary ID.
*
* @returns The formatted envelope secondary ID (template_123)
*/
export const mapTemplateIdToSecondaryId = (templateId: number) => {
return `${envelopeTemplatePrefixId}_${templateId}`;
};
/**
* Maps an envelope secondary ID to a legacy document ID number.
*
* Throws an error if the secondary ID is not a document ID.
*
* @param secondaryId The envelope secondary ID (document_123)
* @returns The legacy document ID number (123)
*/
export const mapSecondaryIdToDocumentId = (secondaryId: string) => {
const parsed = ZDocumentIdSchema.safeParse(secondaryId);
if (!parsed.success) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Invalid document ID',
});
}
return parseInt(parsed.data.split('_')[1]);
};
/**
* Maps an envelope secondary ID to a legacy template ID number.
*
* Throws an error if the secondary ID is not a template ID.
*
* @param secondaryId The envelope secondary ID (template_123)
* @returns The legacy template ID number (123)
*/
export const mapSecondaryIdToTemplateId = (secondaryId: string) => {
const parsed = ZTemplateIdSchema.safeParse(secondaryId);
if (!parsed.success) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Invalid template ID',
});
}
return parseInt(parsed.data.split('_')[1]);
};
export type EnvelopeItemPermissions = {
canTitleBeChanged: boolean;
canFileBeChanged: boolean;
canOrderBeChanged: boolean;
};
export const getEnvelopeItemPermissions = (
envelope: Pick<Envelope, 'completedAt' | 'deletedAt' | 'type' | 'status'>,
recipients: Pick<Recipient, 'role' | 'signingStatus' | 'sendStatus'>[],
): EnvelopeItemPermissions => {
// Always reject completed/rejected/deleted envelopes.
if (
envelope.completedAt ||
envelope.deletedAt ||
envelope.status === DocumentStatus.REJECTED ||
envelope.status === DocumentStatus.COMPLETED
) {
return {
canTitleBeChanged: false,
canFileBeChanged: false,
canOrderBeChanged: false,
};
}
// Templates can always be modified.
if (envelope.type === EnvelopeType.TEMPLATE) {
return {
canTitleBeChanged: true,
canFileBeChanged: true,
canOrderBeChanged: true,
};
}
const hasActiveRecipients = recipients.some(
(recipient) =>
recipient.role !== RecipientRole.CC &&
(recipient.signingStatus === SigningStatus.SIGNED ||
recipient.signingStatus === SigningStatus.REJECTED ||
recipient.sendStatus === SendStatus.SENT),
);
return match(envelope.status)
.with(DocumentStatus.DRAFT, () => ({
canTitleBeChanged: true,
canFileBeChanged: true,
canOrderBeChanged: true,
}))
.with(DocumentStatus.PENDING, () => ({
canTitleBeChanged: true,
canFileBeChanged: false,
canOrderBeChanged: !hasActiveRecipients, // Only allow order changes if no active recipients.
}))
.exhaustive();
};