chore: merge main, resolve biome formatting conflicts

Merge origin/main into feat/external-2fa-codes. Resolve formatting
conflicts caused by biome rollout; preserve both feature streams:
PR's external 2FA token + signing-session 2FA proof additions plus
main's RateLimit/RecipientExpired/signingReminders/date-auto-insert.

In complete-document-with-token.ts, drop the duplicate early
field-fetching block introduced when main moved that logic later
with date auto-insert support; keep the EXTERNAL_TWO_FACTOR_AUTH
check using derivedRecipientActionAuth.
This commit is contained in:
ephraimduncan
2026-05-12 11:46:11 +00:00
parent 9194884fbe
commit 138d663c25
1959 changed files with 93488 additions and 47038 deletions
@@ -1,14 +1,3 @@
import type { DocumentMeta, DocumentVisibility, TemplateType } from '@prisma/client';
import {
DocumentSource,
EnvelopeType,
FolderType,
RecipientRole,
SendStatus,
SigningStatus,
WebhookTriggerEvents,
} from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { PlaceholderInfo } from '@documenso/lib/server-only/pdf/auto-place-fields';
import { convertPlaceholdersToFieldInputs } from '@documenso/lib/server-only/pdf/auto-place-fields';
@@ -20,6 +9,16 @@ import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-reques
import { nanoid, prefixedId } from '@documenso/lib/universal/id';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import type { DocumentMeta, DocumentVisibility, TemplateType } from '@prisma/client';
import {
DocumentSource,
EnvelopeType,
FolderType,
RecipientRole,
SendStatus,
SigningStatus,
WebhookTriggerEvents,
} from '@prisma/client';
import type {
TDocumentAccessAuthTypes,
@@ -30,10 +29,7 @@ import type {
import type { TDocumentFormValues } from '../../types/document-form-values';
import type { TEnvelopeAttachmentType } from '../../types/envelope-attachment';
import type { TFieldAndMeta } from '../../types/field-meta';
import {
ZWebhookDocumentSchema,
mapEnvelopeToWebhookDocumentPayload,
} from '../../types/webhook-payload';
import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../types/webhook-payload';
import { getFileServerSide } from '../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
import { extractDerivedDocumentMeta } from '../../utils/document';
@@ -97,6 +93,14 @@ export type CreateEnvelopeOptions = {
data: string;
type?: TEnvelopeAttachmentType;
}>;
/**
* Whether to bypass adding default recipients.
*
* Defaults to false.
*/
bypassDefaultRecipients?: boolean;
meta?: Partial<Omit<DocumentMeta, 'id'>>;
requestMetadata: ApiRequestMetadata;
};
@@ -110,6 +114,7 @@ export const createEnvelope = async ({
meta,
requestMetadata,
internalVersion,
bypassDefaultRecipients = false,
}: CreateEnvelopeOptions) => {
const {
type,
@@ -198,7 +203,7 @@ export const createEnvelope = async ({
const titleToUse = item.title || title;
const newDocumentData = await putPdfFileServerSide({
const { documentData: newDocumentData } = await putPdfFileServerSide({
name: titleToUse,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(normalizedPdf),
@@ -256,24 +261,8 @@ export const createEnvelope = async ({
// for uploads from the frontend
const timezoneToUse = meta?.timezone || settings.documentTimezone || userTimezone;
const documentMeta = await prisma.documentMeta.create({
data: extractDerivedDocumentMeta(settings, {
...meta,
timezone: timezoneToUse,
}),
});
const secondaryId =
type === EnvelopeType.DOCUMENT
? await incrementDocumentId().then((v) => v.formattedDocumentId)
: await incrementTemplateId().then((v) => v.formattedTemplateId);
const getValidatedDelegatedOwner = async () => {
if (
!settings.delegateDocumentOwnership ||
!delegatedDocumentOwner ||
requestMetadata.source === 'app'
) {
if (!settings.delegateDocumentOwnership || !delegatedDocumentOwner || requestMetadata.source === 'app') {
return null;
}
@@ -302,7 +291,18 @@ export const createEnvelope = async ({
return delegatedOwner;
};
const delegatedOwner = await getValidatedDelegatedOwner();
const [documentMeta, secondaryId, delegatedOwner] = await Promise.all([
prisma.documentMeta.create({
data: extractDerivedDocumentMeta(settings, {
...meta,
timezone: timezoneToUse,
}),
}),
type === EnvelopeType.DOCUMENT
? incrementDocumentId().then((v) => v.formattedDocumentId)
: incrementTemplateId().then((v) => v.formattedTemplateId),
getValidatedDelegatedOwner(),
]);
const envelopeOwnerId = delegatedOwner?.id ?? userId;
const createdEnvelope = await prisma.$transaction(async (tx) => {
@@ -355,17 +355,16 @@ export const createEnvelope = async ({
const firstEnvelopeItem = envelope.envelopeItems[0];
const defaultRecipients = settings.defaultRecipients
? ZDefaultRecipientsSchema.parse(settings.defaultRecipients)
: [];
const defaultRecipients =
settings.defaultRecipients && !bypassDefaultRecipients
? ZDefaultRecipientsSchema.parse(settings.defaultRecipients)
: [];
const mappedDefaultRecipients: CreateEnvelopeRecipientOptions[] = defaultRecipients.map(
(recipient) => ({
email: recipient.email,
name: recipient.name,
role: recipient.role,
}),
);
const mappedDefaultRecipients: CreateEnvelopeRecipientOptions[] = defaultRecipients.map((recipient) => ({
email: recipient.email,
name: recipient.name,
role: recipient.role,
}));
const allRecipients = [...(data.recipients || []), ...mappedDefaultRecipients];
@@ -417,8 +416,7 @@ export const createEnvelope = async ({
signingOrder: recipient.signingOrder,
token: nanoid(),
sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT,
signingStatus:
recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
signingStatus: recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
authOptions: recipientAuthOptions,
fields: {
createMany: {
@@ -431,9 +429,7 @@ export const createEnvelope = async ({
);
// Create fields from PDF placeholders (extracted at upload time).
const itemsWithPlaceholders = envelopeItems.filter(
(item) => item.placeholders && item.placeholders.length > 0,
);
const itemsWithPlaceholders = envelopeItems.filter((item) => item.placeholders && item.placeholders.length > 0);
if (itemsWithPlaceholders.length > 0) {
// Collect all unique recipient placeholder references (e.g. "r1", "r2").
@@ -463,23 +459,18 @@ export const createEnvelope = async ({
// If recipients were not provided, create placeholder recipients even when defaults exist.
if (shouldCreatePlaceholderRecipients) {
const existingRecipientEmails = new Set(
availableRecipients.map((recipient) => recipient.email.toLowerCase()),
);
const existingRecipientEmails = new Set(availableRecipients.map((recipient) => recipient.email.toLowerCase()));
const placeholderRecipients = Array.from(
uniqueRecipientRefs.entries(),
([recipientIndex, name]) => ({
envelopeId: envelope.id,
email: `recipient.${recipientIndex}@documenso.com`,
name,
role: RecipientRole.SIGNER,
signingOrder: recipientIndex,
token: nanoid(),
sendStatus: SendStatus.NOT_SENT,
signingStatus: SigningStatus.NOT_SIGNED,
}),
).filter((recipient) => !existingRecipientEmails.has(recipient.email.toLowerCase()));
const placeholderRecipients = Array.from(uniqueRecipientRefs.entries(), ([recipientIndex, name]) => ({
envelopeId: envelope.id,
email: `recipient.${recipientIndex}@documenso.com`,
name,
role: RecipientRole.SIGNER,
signingOrder: recipientIndex,
token: nanoid(),
sendStatus: SendStatus.NOT_SENT,
signingStatus: SigningStatus.NOT_SIGNED,
})).filter((recipient) => !existingRecipientEmails.has(recipient.email.toLowerCase()));
if (placeholderRecipients.length > 0) {
await tx.recipient.createMany({
@@ -495,9 +486,7 @@ export const createEnvelope = async ({
}
for (const item of itemsWithPlaceholders) {
const envelopeItem = envelope.envelopeItems.find(
(ei) => ei.documentDataId === item.documentDataId,
);
const envelopeItem = envelope.envelopeItems.find((ei) => ei.documentDataId === item.documentDataId);
if (!envelopeItem) {
continue;
@@ -572,7 +561,7 @@ export const createEnvelope = async ({
});
}
// Only create audit logs and webhook events for documents.
// Only create audit logs for documents.
if (type === EnvelopeType.DOCUMENT) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
@@ -609,17 +598,28 @@ export const createEnvelope = async ({
}),
});
}
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_CREATED,
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(createdEnvelope)),
userId,
teamId,
});
}
return createdEnvelope;
});
// Trigger webhook outside the transaction to avoid holding the connection
// open during network I/O.
if (type === EnvelopeType.DOCUMENT) {
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_CREATED,
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(createdEnvelope)),
userId,
teamId,
});
} else if (type === EnvelopeType.TEMPLATE) {
await triggerWebhook({
event: WebhookTriggerEvents.TEMPLATE_CREATED,
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(createdEnvelope)),
userId,
teamId,
});
}
return createdEnvelope;
};
@@ -1,13 +1,10 @@
import { prisma } from '@documenso/prisma';
import { DocumentSource, EnvelopeType, WebhookTriggerEvents } from '@prisma/client';
import pMap from 'p-map';
import { omit } from 'remeda';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import {
ZWebhookDocumentSchema,
mapEnvelopeToWebhookDocumentPayload,
} from '../../types/webhook-payload';
import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../types/webhook-payload';
import { nanoid, prefixedId } from '../../universal/id';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
@@ -18,9 +15,16 @@ export interface DuplicateEnvelopeOptions {
id: EnvelopeIdOptions;
userId: number;
teamId: number;
overrides?: {
duplicateAsTemplate?: boolean;
includeRecipients?: boolean;
includeFields?: boolean;
};
}
export const duplicateEnvelope = async ({ id, userId, teamId }: DuplicateEnvelopeOptions) => {
export const duplicateEnvelope = async ({ id, userId, teamId, overrides }: DuplicateEnvelopeOptions) => {
const { duplicateAsTemplate = false, includeRecipients = true, includeFields = true } = overrides ?? {};
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id,
type: null,
@@ -35,6 +39,9 @@ export const duplicateEnvelope = async ({ id, userId, teamId }: DuplicateEnvelop
title: true,
userId: true,
internalVersion: true,
templateType: true,
publicTitle: true,
publicDescription: true,
envelopeItems: {
include: {
documentData: {
@@ -68,29 +75,42 @@ export const duplicateEnvelope = async ({ id, userId, teamId }: DuplicateEnvelop
});
}
const { legacyNumberId, secondaryId } =
envelope.type === EnvelopeType.DOCUMENT
? await incrementDocumentId().then(({ documentId, formattedDocumentId }) => ({
if (duplicateAsTemplate && envelope.type !== EnvelopeType.DOCUMENT) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Only documents can be saved as templates',
});
}
const targetType = duplicateAsTemplate ? EnvelopeType.TEMPLATE : envelope.type;
const [{ legacyNumberId, secondaryId }, createdDocumentMeta] = await Promise.all([
targetType === EnvelopeType.DOCUMENT
? incrementDocumentId().then(({ documentId, formattedDocumentId }) => ({
legacyNumberId: documentId,
secondaryId: formattedDocumentId,
}))
: await incrementTemplateId().then(({ templateId, formattedTemplateId }) => ({
: incrementTemplateId().then(({ templateId, formattedTemplateId }) => ({
legacyNumberId: templateId,
secondaryId: formattedTemplateId,
}));
})),
prisma.documentMeta.create({
data: {
...omit(envelope.documentMeta, ['id']),
emailSettings: envelope.documentMeta.emailSettings || undefined,
},
}),
]);
const createdDocumentMeta = await prisma.documentMeta.create({
data: {
...omit(envelope.documentMeta, ['id']),
emailSettings: envelope.documentMeta.emailSettings || undefined,
},
});
const duplicatedTemplateType =
envelope.templateType === 'ORGANISATION' && envelope.teamId !== teamId
? 'PRIVATE'
: (envelope.templateType ?? undefined);
const duplicatedEnvelope = await prisma.envelope.create({
data: {
id: prefixedId('envelope'),
secondaryId,
type: envelope.type,
type: targetType,
internalVersion: envelope.internalVersion,
userId,
teamId,
@@ -98,8 +118,10 @@ export const duplicateEnvelope = async ({ id, userId, teamId }: DuplicateEnvelop
documentMetaId: createdDocumentMeta.id,
authOptions: envelope.authOptions || undefined,
visibility: envelope.visibility,
source:
envelope.type === EnvelopeType.DOCUMENT ? DocumentSource.DOCUMENT : DocumentSource.TEMPLATE,
templateType: duplicatedTemplateType,
publicTitle: envelope.publicTitle ?? undefined,
publicDescription: envelope.publicDescription ?? undefined,
source: targetType === EnvelopeType.DOCUMENT ? DocumentSource.DOCUMENT : DocumentSource.TEMPLATE,
},
include: {
recipients: true,
@@ -136,34 +158,41 @@ export const duplicateEnvelope = async ({ id, userId, teamId }: DuplicateEnvelop
}),
);
for (const recipient of envelope.recipients) {
await prisma.recipient.create({
data: {
envelopeId: duplicatedEnvelope.id,
email: recipient.email,
name: recipient.name,
role: recipient.role,
signingOrder: recipient.signingOrder,
token: nanoid(),
fields: {
createMany: {
data: recipient.fields.map((field) => ({
envelopeId: duplicatedEnvelope.id,
envelopeItemId: oldEnvelopeItemToNewEnvelopeItemIdMap[field.envelopeItemId],
type: field.type,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
customText: '',
inserted: false,
fieldMeta: field.fieldMeta as PrismaJson.FieldMeta,
})),
if (includeRecipients) {
await pMap(
envelope.recipients,
async (recipient) =>
prisma.recipient.create({
data: {
envelopeId: duplicatedEnvelope.id,
email: recipient.email,
name: recipient.name,
role: recipient.role,
signingOrder: recipient.signingOrder,
token: nanoid(),
fields: includeFields
? {
createMany: {
data: recipient.fields.map((field) => ({
envelopeId: duplicatedEnvelope.id,
envelopeItemId: oldEnvelopeItemToNewEnvelopeItemIdMap[field.envelopeItemId],
type: field.type,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
customText: '',
inserted: false,
fieldMeta: field.fieldMeta as PrismaJson.FieldMeta,
})),
},
}
: undefined,
},
},
},
});
}),
{ concurrency: 5 },
);
}
if (duplicatedEnvelope.type === EnvelopeType.DOCUMENT) {
@@ -189,7 +218,7 @@ export const duplicateEnvelope = async ({ id, userId, teamId }: DuplicateEnvelop
id: duplicatedEnvelope.id,
envelope: duplicatedEnvelope,
legacyId: {
type: envelope.type,
type: duplicatedEnvelope.type,
id: legacyNumberId,
},
};
@@ -1,12 +1,7 @@
import type {
DocumentSource,
DocumentStatus,
Envelope,
EnvelopeType,
Prisma,
} from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { kyselyPrisma, prisma, sql } from '@documenso/prisma';
import type { DB } from '@documenso/prisma/generated/types';
import type { DocumentSource, DocumentStatus, Envelope, EnvelopeType } from '@prisma/client';
import type { Expression, ExpressionBuilder, SelectQueryBuilder, SqlBool } from 'kysely';
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
import type { FindResultResponse } from '../../types/search-params';
@@ -28,8 +23,77 @@ export type FindEnvelopesOptions = {
};
query?: string;
folderId?: string;
/**
* When true (default), use a windowed count that caps early for faster pagination.
* When false, use a full COUNT(*) for exact totals — preferred for external API consumers.
*/
useWindowedCount?: boolean;
};
/**
* The number of pages ahead of the current page we'll scan for pagination.
*
* Instead of COUNT(*) over the entire result set (which must scan all qualifying rows),
* we fetch at most `offset + COUNT_WINDOW_SIZE * perPage + 1` IDs. This lets Postgres
* stop early once it has enough rows.
*/
const COUNT_WINDOW_SIZE = 100;
/**
* Cap for the recipient search subquery. When searching by recipient email/name,
* we pre-compute matching envelope IDs up to this limit to prevent pathological
* heap scans on broad searches.
*/
const RECIPIENT_SEARCH_CAP = 1000;
// Kysely query builder type for Envelope queries.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type EnvelopeQueryBuilder = SelectQueryBuilder<DB, 'Envelope', any>;
// Expression builder type scoped to Envelope table context.
type EnvelopeExpressionBuilder = ExpressionBuilder<DB, 'Envelope'>;
type RecipientExpressionBuilder = ExpressionBuilder<DB, 'Recipient'>;
/**
* Reusable EXISTS subquery: checks that a Recipient row exists for the given
* envelope with the given email, plus optional extra conditions.
*/
const recipientExists = (
eb: EnvelopeExpressionBuilder,
email: string,
extra?: (qb: RecipientExpressionBuilder) => Expression<SqlBool>,
) => {
let sub = eb
.selectFrom('Recipient')
.whereRef('Recipient.envelopeId', '=', 'Envelope.id')
.where('Recipient.email', '=', email);
if (extra) {
sub = sub.where(extra);
}
return eb.exists(sub.select(sql.lit(1).as('one')));
};
/**
* Reusable EXISTS subquery: checks that the envelope's sender (User) has the given email.
*/
const senderEmailIs = (eb: EnvelopeExpressionBuilder, email: string) =>
eb.exists(
eb
.selectFrom('User')
.whereRef('User.id', '=', 'Envelope.userId')
.where('User.email', '=', email)
.select(sql.lit(1).as('one')),
);
/**
* Find envelopes visible to the requesting user within a team.
*
* Unlike `findDocuments` (used by the UI), being a recipient does NOT override
* document visibility. A user will only see an envelope if its visibility level
* is within their role's threshold, or they are the document owner.
*/
export const findEnvelopes = async ({
userId,
teamId,
@@ -42,133 +106,166 @@ export const findEnvelopes = async ({
orderBy,
query = '',
folderId,
useWindowedCount = true,
}: FindEnvelopesOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
select: {
id: true,
email: true,
name: true,
},
where: { id: userId },
select: { id: true, email: true, name: true },
});
const team = await getTeamById({
userId,
teamId,
});
const team = await getTeamById({ userId, teamId });
const orderByColumn = orderBy?.column ?? 'createdAt';
const orderByDirection = orderBy?.direction ?? 'desc';
const searchQuery = query.trim();
const hasSearch = searchQuery.length > 0;
const searchPattern = `%${searchQuery}%`;
const searchFilter: Prisma.EnvelopeWhereInput = query
? {
OR: [
{ title: { contains: query, mode: 'insensitive' } },
{ externalId: { contains: query, mode: 'insensitive' } },
{ recipients: { some: { name: { contains: query, mode: 'insensitive' } } } },
{ recipients: { some: { email: { contains: query, mode: 'insensitive' } } } },
],
}
: {};
const teamEmail = team.teamEmail?.email ?? null;
const allowedVisibilities = TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole];
const visibilityFilter: Prisma.EnvelopeWhereInput = {
visibility: {
in: TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole],
},
};
// ─── Build Kysely query ──────────────────────────────────────────────
const teamEmailFilters: Prisma.EnvelopeWhereInput[] = [];
let qb: EnvelopeQueryBuilder = kyselyPrisma.$kysely
.selectFrom('Envelope')
.select(['Envelope.id', 'Envelope.createdAt']);
if (team.teamEmail) {
teamEmailFilters.push(
{
user: {
email: team.teamEmail.email,
},
},
{
recipients: {
some: {
email: team.teamEmail.email,
},
},
},
// Folder filter
qb =
folderId !== undefined ? qb.where('Envelope.folderId', '=', folderId) : qb.where('Envelope.folderId', 'is', null);
// Exclude soft-deleted envelopes
qb = qb.where('Envelope.deletedAt', 'is', null);
// Type filter (enum cast)
if (type) {
qb = qb.where('Envelope.type', '=', sql.lit(type));
}
// Template filter
if (templateId) {
qb = qb.where('Envelope.templateId', '=', templateId);
}
// Source filter (enum cast)
if (source) {
qb = qb.where('Envelope.source', '=', sql.lit(source));
}
// Status filter (enum cast)
if (status) {
qb = qb.where('Envelope.status', '=', sql.lit(status));
}
// Search filter: title, externalId, or recipient match via capped subquery
if (hasSearch) {
qb = qb.where(({ or, eb }) =>
or([
eb('Envelope.title', 'ilike', searchPattern),
eb('Envelope.externalId', 'ilike', searchPattern),
eb(
'Envelope.id',
'in',
eb
.selectFrom('Recipient')
.select('Recipient.envelopeId')
.where(({ or: innerOr, eb: innerEb }) =>
innerOr([
innerEb('Recipient.email', 'ilike', searchPattern),
innerEb('Recipient.name', 'ilike', searchPattern),
]),
)
.distinct()
.limit(RECIPIENT_SEARCH_CAP),
),
]),
);
}
const whereClause: Prisma.EnvelopeWhereInput = {
AND: [
{
OR: [
{
teamId: team.id,
...visibilityFilter,
},
{
userId,
},
...teamEmailFilters,
],
},
{
folderId: folderId ?? null,
deletedAt: null,
},
searchFilter,
],
};
// ─── Access control ──────────────────────────────────────────────────
//
// An envelope is visible if ANY of:
// 1. It belongs to this team AND (meets the visibility threshold OR the requesting user is the owner)
// 2. (If team email) The sender's email matches the team email
// 3. (If team email) A recipient's email matches the team email
if (type) {
whereClause.type = type;
const visibilityFilter = (eb: EnvelopeExpressionBuilder) =>
eb.or([
eb(
'Envelope.visibility',
'in',
allowedVisibilities.map((v) => sql.lit(v)),
),
// Owner always sees their own docs within this team
eb('Envelope.userId', '=', user.id),
]);
qb = qb.where((eb) => {
const accessBranches: Expression<SqlBool>[] = [
// Team docs that pass visibility (or are owned by the user)
eb.and([eb('Envelope.teamId', '=', team.id), visibilityFilter(eb)]),
];
if (teamEmail) {
// Docs sent by the team email user
accessBranches.push(senderEmailIs(eb, teamEmail));
// Docs received by the team email
accessBranches.push(recipientExists(eb, teamEmail));
}
return eb.or(accessBranches);
});
// ─── Execute: paginated data + count ──────────────────────────────────
const offset = Math.max(page - 1, 0) * perPage;
const dataQuery = qb.orderBy(`Envelope.${orderByColumn}`, orderByDirection).limit(perPage).offset(offset);
// Count query: either windowed (fast, capped) or full (exact, for API consumers).
const baseCountQuery = qb.clearSelect().select('Envelope.id');
const countQuery = useWindowedCount
? kyselyPrisma.$kysely
.selectFrom(baseCountQuery.limit(offset + COUNT_WINDOW_SIZE * perPage + 1).as('windowed'))
.select(({ fn }) => fn.count<number>('id').as('total'))
: kyselyPrisma.$kysely
.selectFrom(baseCountQuery.as('filtered'))
.select(({ fn }) => fn.count<number>('id').as('total'));
const [dataResult, countResult] = await Promise.all([dataQuery.execute(), countQuery.executeTakeFirstOrThrow()]);
const ids = dataResult.map((row) => row.id);
const totalCount = useWindowedCount
? Math.min(Number(countResult.total ?? 0), offset + COUNT_WINDOW_SIZE * perPage)
: Number(countResult.total ?? 0);
// ─── Hydrate with Prisma ─────────────────────────────────────────────
if (ids.length === 0) {
return {
data: [],
count: totalCount,
currentPage: Math.max(page, 1),
perPage,
totalPages: Math.ceil(totalCount / perPage),
} satisfies FindResultResponse<never[]>;
}
if (templateId) {
whereClause.templateId = templateId;
}
const data = await prisma.envelope.findMany({
where: { id: { in: ids } },
orderBy: { [orderByColumn]: orderByDirection },
include: {
user: { select: { id: true, name: true, email: true } },
recipients: { orderBy: { id: 'asc' } },
team: { select: { id: true, url: true } },
},
});
if (source) {
whereClause.source = source;
}
if (status) {
whereClause.status = status;
}
const [data, count] = await Promise.all([
prisma.envelope.findMany({
where: whereClause,
skip: Math.max(page - 1, 0) * perPage,
take: perPage,
orderBy: {
[orderByColumn]: orderByDirection,
},
include: {
user: {
select: {
id: true,
name: true,
email: true,
},
},
recipients: {
orderBy: {
id: 'asc',
},
},
team: {
select: {
id: true,
url: true,
},
},
},
}),
prisma.envelope.count({
where: whereClause,
}),
]);
// Preserve ordering from the Kysely query
const idOrder = new Map(ids.map((id, index) => [id, index]));
data.sort((a, b) => (idOrder.get(a.id) ?? 0) - (idOrder.get(b.id) ?? 0));
const maskedData = data.map((envelope) =>
maskRecipientTokensForDocument({
@@ -189,9 +286,9 @@ export const findEnvelopes = async ({
return {
data: mappedData,
count,
count: totalCount,
currentPage: Math.max(page, 1),
perPage,
totalPages: Math.ceil(count / perPage),
totalPages: Math.ceil(totalCount / perPage),
} satisfies FindResultResponse<typeof mappedData>;
};
@@ -0,0 +1,104 @@
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { prisma } from '@documenso/prisma';
import type { EnvelopeType } from '@prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { EnvelopeIdOptions } from '../../utils/envelope';
export type GetEditorEnvelopeByIdOptions = {
id: EnvelopeIdOptions;
/**
* The validated team ID.
*/
userId: number;
/**
* The unvalidated team ID.
*/
teamId: number;
/**
* The type of envelope to get.
*
* Set to null to bypass check.
*/
type: EnvelopeType | null;
};
export const getEditorEnvelopeById = async ({ id, userId, teamId, type }: GetEditorEnvelopeByIdOptions) => {
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id,
userId,
teamId,
type,
});
const envelope = await prisma.envelope.findFirst({
where: envelopeWhereInput,
include: {
envelopeItems: {
include: {
documentData: true,
},
orderBy: {
order: 'asc',
},
},
folder: true,
documentMeta: true,
user: {
select: {
id: true,
name: true,
email: true,
},
},
recipients: {
orderBy: {
id: 'asc',
},
},
fields: true,
team: {
select: {
id: true,
url: true,
organisationId: true,
},
},
directLink: {
select: {
directTemplateRecipientId: true,
enabled: true,
id: true,
token: true,
},
},
envelopeAttachments: {
select: {
id: true,
type: true,
label: true,
data: true,
},
},
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope could not be found',
});
}
return {
...envelope,
attachments: envelope.envelopeAttachments,
user: {
id: envelope.user.id,
name: envelope.user.name || '',
email: envelope.user.email,
},
};
};
@@ -1,7 +1,5 @@
import type { Prisma } from '@prisma/client';
import type { EnvelopeType } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import type { EnvelopeType, Prisma } from '@prisma/client';
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
import { AppError, AppErrorCode } from '../../errors/app-error';
@@ -127,12 +125,7 @@ export type GetEnvelopeWhereInputOptions = {
*
* NOTE: Be extremely careful when modifying this function. Needs at minimum two reviewers to approve any changes.
*/
export const getEnvelopeWhereInput = async ({
id,
userId,
teamId,
type,
}: GetEnvelopeWhereInputOptions) => {
export const getEnvelopeWhereInput = async ({ id, userId, teamId, type }: GetEnvelopeWhereInputOptions) => {
// Backup validation incase something goes wrong.
if (!id.id || !userId || !teamId || type === undefined) {
console.error(`[CRTICAL ERROR]: MUST NEVER HAPPEN`);
@@ -1,8 +1,7 @@
import { prisma } from '@documenso/prisma';
import { DocumentStatus, EnvelopeType } from '@prisma/client';
import { match } from 'ts-pattern';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DocumentAccessAuth, type TDocumentAuthMethods } from '../../types/document-auth';
import { extractDocumentAuthMethods } from '../../utils/document-auth';
@@ -84,9 +83,7 @@ export const getEnvelopeForDirectTemplateSigning = async ({
},
});
const recipient = (envelope?.recipients || []).find(
(r) => r.id === envelope?.directLink?.directTemplateRecipientId,
);
const recipient = (envelope?.recipients || []).find((r) => r.id === envelope?.directLink?.directTemplateRecipientId);
if (!envelope || !recipient) {
throw new AppError(AppErrorCode.NOT_FOUND, {
@@ -146,7 +143,7 @@ export const getEnvelopeForDirectTemplateSigning = async ({
...recipient,
directToken: envelope.directLink?.token || '',
fields: recipient.fields.map((field) => {
const autoInsertValue = extractFieldAutoInsertValues(field);
const autoInsertValue = extractFieldAutoInsertValues(field, recipient);
if (!autoInsertValue) {
return field;
@@ -163,6 +160,7 @@ export const getEnvelopeForDirectTemplateSigning = async ({
isRecipientsTurn: true,
isCompleted: false,
isRejected: false,
isExpired: false,
sender,
settings: {
includeSenderDetails: settings.includeSenderDetails,
@@ -1,6 +1,3 @@
import { DocumentSigningOrder, DocumentStatus, EnvelopeType, SigningStatus } from '@prisma/client';
import { z } from 'zod';
import { prisma } from '@documenso/prisma';
import DocumentMetaSchema from '@documenso/prisma/generated/zod/modelSchema/DocumentMetaSchema';
import EnvelopeItemSchema from '@documenso/prisma/generated/zod/modelSchema/EnvelopeItemSchema';
@@ -8,11 +5,14 @@ import EnvelopeSchema from '@documenso/prisma/generated/zod/modelSchema/Envelope
import SignatureSchema from '@documenso/prisma/generated/zod/modelSchema/SignatureSchema';
import TeamSchema from '@documenso/prisma/generated/zod/modelSchema/TeamSchema';
import UserSchema from '@documenso/prisma/generated/zod/modelSchema/UserSchema';
import { DocumentSigningOrder, DocumentStatus, EnvelopeType, SigningStatus } from '@prisma/client';
import { z } from 'zod';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TDocumentAuthMethods } from '../../types/document-auth';
import { ZEnvelopeFieldSchema, ZFieldSchema } from '../../types/field';
import { ZRecipientLiteSchema } from '../../types/recipient';
import { isRecipientExpired } from '../../utils/recipients';
import { isRecipientAuthorized } from '../document/is-recipient-authorized';
import { getTeamSettings } from '../team/get-team-settings';
@@ -56,7 +56,9 @@ export const ZEnvelopeForSigningResponse = z.object({
email: true,
name: true,
documentDeletedAt: true,
expired: true,
expired: true, //!: deprecated Not in use. To be removed in a future migration.
expiresAt: true,
expirationNotifiedAt: true,
signedAt: true,
authOptions: true,
signingOrder: true,
@@ -77,6 +79,7 @@ export const ZEnvelopeForSigningResponse = z.object({
id: true,
title: true,
order: true,
documentDataId: true,
}).array(),
team: TeamSchema.pick({
@@ -102,7 +105,8 @@ export const ZEnvelopeForSigningResponse = z.object({
email: true,
name: true,
documentDeletedAt: true,
expired: true,
expiresAt: true,
expirationNotifiedAt: true,
signedAt: true,
authOptions: true,
token: true,
@@ -126,6 +130,7 @@ export const ZEnvelopeForSigningResponse = z.object({
isCompleted: z.boolean(),
isRejected: z.boolean(),
isExpired: z.boolean(),
isRecipientsTurn: z.boolean(),
sender: z.object({
@@ -258,10 +263,7 @@ export const getEnvelopeForRecipientSigning = async ({
const currentRecipientIndex = envelope.recipients.findIndex((r) => r.token === token);
if (
envelope.documentMeta.signingOrder === DocumentSigningOrder.SEQUENTIAL &&
currentRecipientIndex !== -1
) {
if (envelope.documentMeta.signingOrder === DocumentSigningOrder.SEQUENTIAL && currentRecipientIndex !== -1) {
for (let i = 0; i < currentRecipientIndex; i++) {
if (envelope.recipients[i].signingStatus !== SigningStatus.SIGNED) {
isRecipientsTurn = false;
@@ -285,12 +287,9 @@ export const getEnvelopeForRecipientSigning = async ({
recipient,
recipientSignature,
isRecipientsTurn,
isCompleted:
recipient.signingStatus === SigningStatus.SIGNED ||
envelope.status === DocumentStatus.COMPLETED,
isRejected:
recipient.signingStatus === SigningStatus.REJECTED ||
envelope.status === DocumentStatus.REJECTED,
isCompleted: recipient.signingStatus === SigningStatus.SIGNED || envelope.status === DocumentStatus.COMPLETED,
isRejected: recipient.signingStatus === SigningStatus.REJECTED || envelope.status === DocumentStatus.REJECTED,
isExpired: isRecipientExpired(recipient),
sender,
settings: {
includeSenderDetails: settings.includeSenderDetails,
@@ -1,6 +1,5 @@
import { DocumentStatus, EnvelopeType } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, EnvelopeType } from '@prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
@@ -1,6 +1,5 @@
import type { EnvelopeType, Prisma } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import type { EnvelopeType, Prisma } from '@prisma/client';
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
import { AppError, AppErrorCode } from '../../errors/app-error';
@@ -42,12 +41,7 @@ export type GetEnvelopesByIdsOptions = {
*
* NOTE: Be extremely careful when modifying this function. Needs at minimum two reviewers to approve any changes.
*/
export const getEnvelopesByIds = async ({
ids,
userId,
teamId,
type,
}: GetEnvelopesByIdsOptions) => {
export const getEnvelopesByIds = async ({ ids, userId, teamId, type }: GetEnvelopesByIdsOptions) => {
const { envelopeWhereInput } = await getMultipleEnvelopeWhereInput({
ids,
userId,
@@ -1,6 +1,5 @@
import { DocumentStatus, EnvelopeType } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, EnvelopeType } from '@prisma/client';
import { deletedAccountServiceAccount } from '../user/service-accounts/deleted-account';
@@ -5,10 +5,7 @@ export type TransferTeamEnvelopesOptions = {
targetTeamId: number;
};
export const transferTeamEnvelopes = async ({
sourceTeamId,
targetTeamId,
}: TransferTeamEnvelopesOptions) => {
export const transferTeamEnvelopes = async ({ sourceTeamId, targetTeamId }: TransferTeamEnvelopesOptions) => {
await prisma.envelope.updateMany({
where: {
teamId: sourceTeamId,
@@ -1,20 +1,21 @@
import type { DocumentMeta, DocumentVisibility, Prisma, TemplateType } from '@prisma/client';
import { EnvelopeType, FolderType } from '@prisma/client';
import { DocumentStatus } from '@prisma/client';
import { isDeepEqual } from 'remeda';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import type { CreateDocumentAuditLogDataResponse } from '@documenso/lib/utils/document-audit-logs';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import type { DocumentMeta, DocumentVisibility, Prisma, TemplateType } from '@prisma/client';
import { DocumentStatus, EnvelopeType, FolderType, WebhookTriggerEvents } from '@prisma/client';
import { isDeepEqual } from 'remeda';
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth';
import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../types/webhook-payload';
import { createDocumentAuthOptions, extractDocumentAuthMethods } from '../../utils/document-auth';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { buildTeamWhereQuery, canAccessTeamDocument } from '../../utils/teams';
import { recomputeNextReminderForEnvelope } from '../recipient/update-recipient-next-reminder';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { getEnvelopeWhereInput } from './get-envelope-by-id';
export type UpdateEnvelopeOptions = {
@@ -75,10 +76,7 @@ export const updateEnvelope = async ({
});
}
if (
envelope.type !== EnvelopeType.TEMPLATE &&
(data.publicTitle || data.publicDescription || data.templateType)
) {
if (envelope.type !== EnvelopeType.TEMPLATE && (data.publicTitle || data.publicDescription || data.templateType)) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'You cannot update the template fields for document type envelopes',
});
@@ -92,11 +90,7 @@ export const updateEnvelope = async ({
const isEnvelopeOwner = envelope.userId === userId;
// Validate whether the new visibility setting is allowed for the current user.
if (
!isEnvelopeOwner &&
data?.visibility &&
!canAccessTeamDocument(team.currentTeamRole, data.visibility)
) {
if (!isEnvelopeOwner && data?.visibility && !canAccessTeamDocument(team.currentTeamRole, data.visibility)) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to update the envelope visibility',
});
@@ -110,10 +104,8 @@ export const updateEnvelope = async ({
const documentGlobalActionAuth = documentAuthOption?.globalActionAuth ?? null;
// If the new global auth values aren't passed in, fallback to the current document values.
const newGlobalAccessAuth =
data?.globalAccessAuth === undefined ? documentGlobalAccessAuth : data.globalAccessAuth;
const newGlobalActionAuth =
data?.globalActionAuth === undefined ? documentGlobalActionAuth : data.globalActionAuth;
const newGlobalAccessAuth = data?.globalAccessAuth === undefined ? documentGlobalAccessAuth : data.globalAccessAuth;
const newGlobalActionAuth = data?.globalActionAuth === undefined ? documentGlobalActionAuth : data.globalActionAuth;
// Check if user has permission to set the global action auth.
if (newGlobalActionAuth.length > 0 && !envelope.team.organisation.organisationClaim.flags.cfr21) {
@@ -145,7 +137,7 @@ export const updateEnvelope = async ({
}
}
let folderUpdateQuery: Prisma.FolderUpdateOneWithoutEnvelopesNestedInput | undefined = undefined;
let folderUpdateQuery: Prisma.FolderUpdateOneWithoutEnvelopesNestedInput | undefined;
// Validate folder ID.
if (data.folderId) {
@@ -186,26 +178,21 @@ export const updateEnvelope = async ({
const isTitleSame = data.title === undefined || data.title === envelope.title;
const isExternalIdSame = data.externalId === undefined || data.externalId === envelope.externalId;
const isGlobalAccessSame =
documentGlobalAccessAuth === undefined ||
isDeepEqual(documentGlobalAccessAuth, newGlobalAccessAuth);
documentGlobalAccessAuth === undefined || isDeepEqual(documentGlobalAccessAuth, newGlobalAccessAuth);
const isGlobalActionSame =
documentGlobalActionAuth === undefined ||
isDeepEqual(documentGlobalActionAuth, newGlobalActionAuth);
const isDocumentVisibilitySame =
data.visibility === undefined || data.visibility === envelope.visibility;
documentGlobalActionAuth === undefined || isDeepEqual(documentGlobalActionAuth, newGlobalActionAuth);
const isDocumentVisibilitySame = data.visibility === undefined || data.visibility === envelope.visibility;
const isFolderSame = data.folderId === undefined || data.folderId === envelope.folderId;
const isTemplateTypeSame =
data.templateType === undefined || data.templateType === envelope.templateType;
const isTemplateTypeSame = data.templateType === undefined || data.templateType === envelope.templateType;
const isPublicDescriptionSame =
data.publicDescription === undefined || data.publicDescription === envelope.publicDescription;
const isPublicTitleSame =
data.publicTitle === undefined || data.publicTitle === envelope.publicTitle;
const isPublicTitleSame = data.publicTitle === undefined || data.publicTitle === envelope.publicTitle;
const auditLogs: CreateDocumentAuditLogDataResponse[] = [];
if (!isTitleSame && envelope.status !== DocumentStatus.DRAFT) {
if (!isTitleSame && envelope.status !== DocumentStatus.DRAFT && envelope.status !== DocumentStatus.PENDING) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'You cannot update the title if the envelope has been sent',
message: 'Envelope title can only be updated while in draft or pending status',
});
}
@@ -309,8 +296,8 @@ export const updateEnvelope = async ({
// return envelope;
// }
return await prisma.$transaction(async (tx) => {
const updatedEnvelope = await tx.envelope.update({
const updatedEnvelope = await prisma.$transaction(async (tx) => {
const result = await tx.envelope.update({
where: {
id: envelope.id,
},
@@ -331,6 +318,10 @@ export const updateEnvelope = async ({
},
},
},
include: {
documentMeta: true,
recipients: true,
},
});
if (envelope.type === EnvelopeType.DOCUMENT) {
@@ -339,6 +330,25 @@ export const updateEnvelope = async ({
});
}
return updatedEnvelope;
return result;
});
// Recompute reminders for active recipients when reminder settings change.
if (meta && 'reminderSettings' in meta) {
await recomputeNextReminderForEnvelope(envelope.id);
}
if (envelope.type === EnvelopeType.TEMPLATE) {
await triggerWebhook({
event: WebhookTriggerEvents.TEMPLATE_UPDATED,
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(updatedEnvelope)),
userId,
teamId,
});
}
// deconstruct to remove the recipients and documentMeta from the returned object since they aren't needed and can be large.
const { recipients: _recipients, documentMeta: _documentMeta, ...finalEnvelope } = updatedEnvelope;
return finalEnvelope;
};