feat: add envelopes (#2025)

This PR is handles the changes required to support envelopes. The new
envelope editor/signing page will be hidden during release.

The core changes here is to migrate the documents and templates model to
a centralized envelopes model.

Even though Documents and Templates are removed, from the user
perspective they will still exist as we remap envelopes to documents and
templates.
This commit is contained in:
David Nguyen
2025-10-14 21:56:36 +11:00
committed by GitHub
parent 7b17156e56
commit 7f09ba72f4
447 changed files with 33467 additions and 9622 deletions

View File

@ -22,7 +22,7 @@ import { ZRecipientAuthOptionsSchema } from '../types/document-auth';
import type { ApiRequestMetadata, RequestMetadata } from '../universal/extract-request-metadata';
type CreateDocumentAuditLogDataOptions<T = TDocumentAuditLog['type']> = {
documentId: number;
envelopeId: string;
type: T;
data: Extract<TDocumentAuditLog, { type: T }>['data'];
user?: { email?: string | null; id?: number | null; name?: string | null } | null;
@ -32,13 +32,13 @@ type CreateDocumentAuditLogDataOptions<T = TDocumentAuditLog['type']> = {
export type CreateDocumentAuditLogDataResponse = Pick<
DocumentAuditLog,
'type' | 'ipAddress' | 'userAgent' | 'email' | 'userId' | 'name' | 'documentId'
'type' | 'ipAddress' | 'userAgent' | 'email' | 'userId' | 'name' | 'envelopeId'
> & {
data: TDocumentAuditLog['data'];
};
export const createDocumentAuditLogData = <T extends TDocumentAuditLog['type']>({
documentId,
envelopeId,
type,
data,
user,
@ -62,7 +62,7 @@ export const createDocumentAuditLogData = <T extends TDocumentAuditLog['type']>(
return {
type,
data,
documentId,
envelopeId,
userId,
email,
name,
@ -203,7 +203,6 @@ export const diffDocumentMetaChanges = (
const oldMessage = oldData?.message ?? '';
const oldSubject = oldData?.subject ?? '';
const oldTimezone = oldData?.timezone ?? '';
const oldPassword = oldData?.password ?? null;
const oldRedirectUrl = oldData?.redirectUrl ?? '';
const oldEmailId = oldData?.emailId || null;
const oldEmailReplyTo = oldData?.emailReplyTo || null;
@ -258,12 +257,6 @@ export const diffDocumentMetaChanges = (
});
}
if (oldPassword !== newData.password) {
diffs.push({
type: DOCUMENT_META_DIFF_TYPE.PASSWORD,
});
}
if (oldEmailId !== newEmailId) {
diffs.push({
type: DOCUMENT_META_DIFF_TYPE.EMAIL_ID,

View File

@ -1,4 +1,4 @@
import type { Document, Recipient } from '@prisma/client';
import type { Envelope, Recipient } from '@prisma/client';
import type {
TDocumentAuthOptions,
@ -10,7 +10,7 @@ import { DocumentAuth } from '../types/document-auth';
import { ZDocumentAuthOptionsSchema, ZRecipientAuthOptionsSchema } from '../types/document-auth';
type ExtractDocumentAuthMethodsOptions = {
documentAuth: Document['authOptions'];
documentAuth: Envelope['authOptions'];
recipientAuth?: Recipient['authOptions'];
};

View File

@ -1,20 +0,0 @@
import { DocumentVisibility, TeamMemberRole } from '@prisma/client';
export const determineDocumentVisibility = (
globalVisibility: DocumentVisibility | null | undefined,
userRole: TeamMemberRole,
): DocumentVisibility => {
if (globalVisibility) {
return globalVisibility;
}
if (userRole === TeamMemberRole.ADMIN) {
return DocumentVisibility.ADMIN;
}
if (userRole === TeamMemberRole.MANAGER) {
return DocumentVisibility.MANAGER_AND_ABOVE;
}
return DocumentVisibility.EVERYONE;
};

View File

@ -1,15 +1,20 @@
import type {
Document,
DocumentMeta,
Envelope,
OrganisationGlobalSettings,
TemplateMeta,
Recipient,
Team,
User,
} from '@prisma/client';
import { DocumentDistributionMethod, DocumentSigningOrder, DocumentStatus } from '@prisma/client';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '../constants/time-zones';
import type { TDocumentLite, TDocumentMany } from '../types/document';
import { DEFAULT_DOCUMENT_EMAIL_SETTINGS } from '../types/document-email';
import { mapSecondaryIdToDocumentId } from './envelope';
import { mapRecipientToLegacyRecipient } from './recipients';
export const isDocumentCompleted = (document: Pick<Document, 'status'> | DocumentStatus) => {
export const isDocumentCompleted = (document: Pick<Envelope, 'status'> | DocumentStatus) => {
const status = typeof document === 'string' ? document : document.status;
return status === DocumentStatus.COMPLETED || status === DocumentStatus.REJECTED;
@ -29,7 +34,7 @@ export const isDocumentCompleted = (document: Pick<Document, 'status'> | Documen
*/
export const extractDerivedDocumentMeta = (
settings: Omit<OrganisationGlobalSettings, 'id'>,
overrideMeta: Partial<DocumentMeta | TemplateMeta> | undefined | null,
overrideMeta: Partial<DocumentMeta> | undefined | null,
) => {
const meta = overrideMeta ?? {};
@ -41,7 +46,6 @@ export const extractDerivedDocumentMeta = (
dateFormat: meta.dateFormat || settings.documentDateFormat,
message: meta.message || null,
subject: meta.subject || null,
password: meta.password || null,
redirectUrl: meta.redirectUrl || null,
signingOrder: meta.signingOrder || DocumentSigningOrder.PARALLEL,
@ -58,5 +62,87 @@ export const extractDerivedDocumentMeta = (
emailReplyTo: meta.emailReplyTo ?? settings.emailReplyTo,
emailSettings:
meta.emailSettings || settings.emailDocumentSettings || DEFAULT_DOCUMENT_EMAIL_SETTINGS,
} satisfies Omit<DocumentMeta, 'id' | 'documentId'>;
} satisfies Omit<DocumentMeta, 'id'>;
};
/**
* Map an envelope to a legacy document lite response entity.
*
* Do not use spread operator here to avoid unexpected behavior.
*/
export const mapEnvelopeToDocumentLite = (envelope: Envelope): TDocumentLite => {
const documentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
return {
id: documentId, // Use legacy ID.
envelopeId: envelope.id,
visibility: envelope.visibility,
status: envelope.status,
source: envelope.source,
externalId: envelope.externalId,
userId: envelope.userId,
authOptions: envelope.authOptions,
formValues: envelope.formValues,
title: envelope.title,
createdAt: envelope.createdAt,
documentDataId: '', // Backwards compatibility.
updatedAt: envelope.updatedAt,
completedAt: envelope.completedAt,
deletedAt: envelope.deletedAt,
teamId: envelope.teamId,
folderId: envelope.folderId,
useLegacyFieldInsertion: envelope.useLegacyFieldInsertion,
templateId: envelope.templateId,
};
};
type MapEnvelopeToDocumentManyOptions = Envelope & {
user: Pick<User, 'id' | 'name' | 'email'>;
team: Pick<Team, 'id' | 'url'>;
recipients: Recipient[];
};
/**
* Map an envelope to a legacy document many response entity.
*
* Do not use spread operator here to avoid unexpected behavior.
*/
export const mapEnvelopesToDocumentMany = (
envelope: MapEnvelopeToDocumentManyOptions,
): TDocumentMany => {
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
return {
id: legacyDocumentId, // Use legacy ID.
envelopeId: envelope.id,
visibility: envelope.visibility,
status: envelope.status,
source: envelope.source,
externalId: envelope.externalId,
userId: envelope.userId,
authOptions: envelope.authOptions,
formValues: envelope.formValues,
title: envelope.title,
createdAt: envelope.createdAt,
documentDataId: '', // Backwards compatibility.
updatedAt: envelope.updatedAt,
completedAt: envelope.completedAt,
deletedAt: envelope.deletedAt,
teamId: envelope.teamId,
folderId: envelope.folderId,
useLegacyFieldInsertion: envelope.useLegacyFieldInsertion,
templateId: envelope.templateId,
user: {
id: envelope.userId,
name: envelope.user.name,
email: envelope.user.email,
},
team: {
id: envelope.teamId,
url: envelope.team.url,
},
recipients: envelope.recipients.map((recipient) =>
mapRecipientToLegacyRecipient(recipient, envelope),
),
};
};

View File

@ -0,0 +1,167 @@
import type { Envelope, Recipient } from '@prisma/client';
import { DocumentStatus, EnvelopeType, 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,}$/);
export type EnvelopeIdOptions =
| {
type: 'envelopeId';
id: string;
}
| {
type: 'documentId';
id: number;
}
| {
type: 'templateId';
id: 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();
};
/**
* 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 const canEnvelopeItemsBeModified = (
envelope: Pick<Envelope, 'completedAt' | 'deletedAt' | 'type' | 'status'>,
recipients: Recipient[],
) => {
if (envelope.completedAt || envelope.deletedAt || envelope.status !== DocumentStatus.DRAFT) {
return false;
}
if (envelope.type === EnvelopeType.TEMPLATE) {
return true;
}
if (
recipients.some(
(recipient) =>
recipient.signingStatus === SigningStatus.SIGNED ||
recipient.sendStatus === SendStatus.SENT,
)
) {
return false;
}
return true;
};

View File

@ -1,4 +1,6 @@
import type { Field } from '@prisma/client';
import { type Envelope, type Field } from '@prisma/client';
import { extractLegacyIds } from '../universal/id';
/**
* Sort the fields by the Y position on the document.
@ -63,3 +65,15 @@ export const validateFieldsUninserted = (): boolean => {
return errorElements.length === 0;
};
export const mapFieldToLegacyField = (
field: Field,
envelope: Pick<Envelope, 'type' | 'secondaryId'>,
) => {
const legacyId = extractLegacyIds(envelope);
return {
...field,
...legacyId,
};
};

View File

@ -1,14 +1,14 @@
import type { User } from '@prisma/client';
import type { DocumentWithRecipients } from '@documenso/prisma/types/document-with-recipient';
import type { EnvelopeWithRecipients } from '@documenso/prisma/types/document-with-recipient';
export type MaskRecipientTokensForDocumentOptions<T extends DocumentWithRecipients> = {
export type MaskRecipientTokensForDocumentOptions<T extends EnvelopeWithRecipients> = {
document: T;
user?: Pick<User, 'id' | 'email' | 'name'>;
user?: Pick<User, 'id' | 'email'>;
token?: string;
};
export const maskRecipientTokensForDocument = <T extends DocumentWithRecipients>({
export const maskRecipientTokensForDocument = <T extends EnvelopeWithRecipients>({
document,
user,
token,

View File

@ -1,6 +1,8 @@
import type { Envelope } from '@prisma/client';
import { type Field, type Recipient, RecipientRole, SigningStatus } from '@prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
import { extractLegacyIds } from '../universal/id';
export const formatSigningLink = (token: string) => `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${token}`;
@ -44,3 +46,15 @@ export const canRecipientFieldsBeModified = (recipient: Recipient, fields: Field
return recipient.role !== RecipientRole.VIEWER && recipient.role !== RecipientRole.CC;
};
export const mapRecipientToLegacyRecipient = (
recipient: Recipient,
envelope: Pick<Envelope, 'type' | 'secondaryId'>,
) => {
const legacyId = extractLegacyIds(envelope);
return {
...recipient,
...legacyId,
};
};

View File

@ -1,4 +1,9 @@
import type { OrganisationGlobalSettings, Prisma, TeamGlobalSettings } from '@prisma/client';
import type {
DocumentVisibility,
OrganisationGlobalSettings,
Prisma,
TeamGlobalSettings,
} from '@prisma/client';
import type { TeamGroup } from '@documenso/prisma/generated/types';
import type { TeamMemberRole } from '@documenso/prisma/generated/types';
@ -6,6 +11,7 @@ import type { TeamMemberRole } from '@documenso/prisma/generated/types';
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
import {
LOWEST_TEAM_ROLE,
TEAM_DOCUMENT_VISIBILITY_MAP,
TEAM_MEMBER_ROLE_HIERARCHY,
TEAM_MEMBER_ROLE_PERMISSIONS_MAP,
} from '../constants/teams';
@ -48,6 +54,17 @@ export const canExecuteTeamAction = (
return TEAM_MEMBER_ROLE_PERMISSIONS_MAP[action].some((i) => i === role);
};
/**
* Determines whether a team role can access the visibility of a document.
*
* @param action The action the user is trying to execute.
* @param role The current role of the user.
* @returns Whether the user can execute the action.
*/
export const canAccessTeamDocument = (role: TeamMemberRole, visibility: DocumentVisibility) => {
return TEAM_DOCUMENT_VISIBILITY_MAP[role].some((i) => i === visibility);
};
/**
* Compares the provided `currentUserRole` with the provided `roleToCheck` to determine
* whether the `currentUserRole` has permission to modify the `roleToCheck`.
@ -106,7 +123,7 @@ export const extractTeamSignatureSettings = (
return signatureTypes;
};
type BuildTeamWhereQueryOptions = {
export type BuildTeamWhereQueryOptions = {
teamId: number | undefined;
userId: number;
roles?: TeamMemberRole[];

View File

@ -1,6 +1,9 @@
import type { Recipient } from '@prisma/client';
import type { Envelope } from '@prisma/client';
import { type Recipient } from '@prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
import type { TTemplateLite } from '../types/template';
import { mapSecondaryIdToTemplateId } from './envelope';
export const formatDirectTemplatePath = (token: string) => {
return `${NEXT_PUBLIC_WEBAPP_URL()}/d/${token}`;
@ -42,3 +45,26 @@ export const generateAvaliableRecipientPlaceholder = (currentRecipients: Recipie
return recipientPlaceholder;
};
export const mapEnvelopeToTemplateLite = (envelope: Envelope): TTemplateLite => {
const legacyTemplateId = mapSecondaryIdToTemplateId(envelope.secondaryId);
return {
id: legacyTemplateId,
envelopeId: envelope.secondaryId,
type: envelope.templateType,
visibility: envelope.visibility,
externalId: envelope.externalId,
title: envelope.title,
userId: envelope.userId,
teamId: envelope.teamId,
authOptions: envelope.authOptions,
createdAt: envelope.createdAt,
updatedAt: envelope.updatedAt,
publicTitle: envelope.publicTitle,
publicDescription: envelope.publicDescription,
folderId: envelope.folderId,
useLegacyFieldInsertion: envelope.useLegacyFieldInsertion,
templateDocumentDataId: '',
};
};