Merge branch 'main' into feat/document-file-conversion

Resolved conflicts:
- create-document-data.ts: merged initialData + originalData/originalMimeType
- files.helpers.ts: kept both imports
- envelope-drop-zone-wrapper.tsx: adopted buildDropzoneRejectionDescription
- create-envelope-items.ts: adopted UNSAFE_createEnvelopeItems
- create-envelope.ts: integrated convertToPdfIfNeeded into createEnvelopeRouteCaller

Extended putPdfFileServerSide to accept { initialData, originalData,
originalMimeType } options; updated seal-document.handler call site.
This commit is contained in:
ephraimduncan
2026-04-20 10:11:35 +00:00
1127 changed files with 107456 additions and 22991 deletions
@@ -8,7 +8,10 @@ import {
SigningStatus,
WebhookTriggerEvents,
} from '@prisma/client';
import { DateTime } from 'luxon';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import {
DOCUMENT_AUDIT_LOG_TYPE,
RECIPIENT_DIFF_TYPE,
@@ -29,6 +32,7 @@ import {
import { extractDocumentAuthMethods } from '../../utils/document-auth';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { mapSecondaryIdToDocumentId, unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
import { assertRecipientNotExpired } from '../../utils/recipients';
import { getIsRecipientsTurnToSign } from '../recipient/get-is-recipient-turn';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { isRecipientAuthorized } from './is-recipient-authorized';
@@ -94,6 +98,8 @@ export const completeDocumentWithToken = async ({
const [recipient] = envelope.recipients;
assertRecipientNotExpired(recipient);
if (recipient.signingStatus === SigningStatus.SIGNED) {
throw new Error(`Recipient ${recipient.id} has already signed`);
}
@@ -106,7 +112,9 @@ export const completeDocumentWithToken = async ({
}
if (envelope.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL) {
const isRecipientsTurn = await getIsRecipientsTurnToSign({ token: recipient.token });
const isRecipientsTurn = await getIsRecipientsTurnToSign({
token: recipient.token,
});
if (!isRecipientsTurn) {
throw new Error(
@@ -115,46 +123,6 @@ export const completeDocumentWithToken = async ({
}
}
const fields = await prisma.field.findMany({
where: {
envelopeId: envelope.id,
recipientId: recipient.id,
},
});
if (fieldsContainUnsignedRequiredField(fields)) {
throw new Error(`Recipient ${recipient.id} has unsigned fields`);
}
let recipientName = recipient.name;
let recipientEmail = recipient.email;
// Only trim the name if it's been derived.
if (!recipientName) {
recipientName = (
recipientOverride?.name ||
fields.find((field) => field.type === FieldType.NAME)?.customText ||
''
).trim();
}
// Only trim the email if it's been derived.
if (!recipient.email) {
recipientEmail = (
recipientOverride?.email ||
fields.find((field) => field.type === FieldType.EMAIL)?.customText ||
''
)
.trim()
.toLowerCase();
}
if (!recipientEmail) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Recipient email is required',
});
}
// Check ACCESS AUTH 2FA validation during document completion
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
documentAuth: envelope.authOptions,
@@ -213,6 +181,112 @@ export const completeDocumentWithToken = async ({
});
}
let fields = await prisma.field.findMany({
where: {
envelopeId: envelope.id,
recipientId: recipient.id,
},
});
// This should be scoped to the current recipient.
const uninsertedDateFields = fields.filter(
(field) => field.type === FieldType.DATE && !field.inserted,
);
let recipientName = recipient.name;
let recipientEmail = recipient.email;
// Only trim the name if it's been derived.
if (!recipientName) {
recipientName = (
recipientOverride?.name ||
fields.find((field) => field.type === FieldType.NAME)?.customText ||
''
).trim();
}
// Only trim the email if it's been derived.
if (!recipient.email) {
recipientEmail = (
recipientOverride?.email ||
fields.find((field) => field.type === FieldType.EMAIL)?.customText ||
''
)
.trim()
.toLowerCase();
}
if (!recipientEmail) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Recipient email is required',
});
}
// Auto-insert all un-inserted date fields for V2 envelopes at completion time.
if (envelope.internalVersion === 2 && uninsertedDateFields.length > 0) {
const formattedDate = DateTime.now()
.setZone(envelope.documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE)
.toFormat(envelope.documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT);
const newDateFieldValues = {
customText: formattedDate,
inserted: true,
};
await prisma.field.updateMany({
where: {
id: {
in: uninsertedDateFields.map((field) => field.id),
},
},
data: {
...newDateFieldValues,
},
});
// Create audit log entries for each auto-inserted date field.
await prisma.documentAuditLog.createMany({
data: uninsertedDateFields.map((field) =>
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED,
envelopeId: envelope.id,
user: {
email: recipientEmail,
name: recipientName,
},
requestMetadata,
data: {
recipientEmail: recipientEmail,
recipientId: recipient.id,
recipientName: recipientName,
recipientRole: recipient.role,
fieldId: field.secondaryId,
field: {
type: FieldType.DATE,
data: formattedDate,
},
},
}),
),
});
// Update the local fields array so the subsequent validation check passes.
fields = fields.map((field) => {
if (field.type === FieldType.DATE && !field.inserted) {
return {
...field,
...newDateFieldValues,
};
}
return field;
});
}
if (fieldsContainUnsignedRequiredField(fields)) {
throw new Error(`Recipient ${recipient.id} has unsigned fields`);
}
await prisma.$transaction(async (tx) => {
await tx.recipient.update({
where: {
@@ -283,6 +357,18 @@ export const completeDocumentWithToken = async ({
});
});
const envelopeWithRelations = await prisma.envelope.findUniqueOrThrow({
where: { id: envelope.id },
include: { documentMeta: true, recipients: true },
});
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_RECIPIENT_COMPLETED,
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelopeWithRelations)),
userId: envelope.userId,
teamId: envelope.teamId,
});
await jobs.triggerJob({
name: 'send.recipient.signed.email',
payload: {
@@ -356,6 +442,7 @@ export const completeDocumentWithToken = async ({
where: { id: nextRecipient.id },
data: {
sendStatus: SendStatus.SENT,
sentAt: new Date(),
...(nextSigner && envelope.documentMeta?.allowDictateNextSigner
? {
name: nextSigner.name,
@@ -364,16 +451,16 @@ export const completeDocumentWithToken = async ({
: {}),
},
});
});
await jobs.triggerJob({
name: 'send.signing.requested.email',
payload: {
userId: envelope.userId,
documentId: legacyDocumentId,
recipientId: nextRecipient.id,
requestMetadata,
},
});
await jobs.triggerJob({
name: 'send.signing.requested.email',
payload: {
userId: envelope.userId,
documentId: legacyDocumentId,
recipientId: nextRecipient.id,
requestMetadata,
},
});
}
}
@@ -93,6 +93,13 @@ export const deleteDocument = async ({
user,
requestMetadata,
});
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_CANCELLED,
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelope)),
userId,
teamId,
});
}
// Continue to hide the document from the user if they are a recipient.
@@ -112,13 +119,6 @@ export const deleteDocument = async ({
});
}
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_CANCELLED,
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelope)),
userId,
teamId,
});
return envelope;
};
File diff suppressed because it is too large Load Diff
@@ -13,7 +13,7 @@ export const getDocumentByAccessToken = async ({ token }: GetDocumentByAccessTok
throw new Error('Missing token');
}
const result = await prisma.envelope.findFirstOrThrow({
const result = await prisma.envelope.findFirst({
where: {
type: EnvelopeType.DOCUMENT,
status: DocumentStatus.COMPLETED,
@@ -58,6 +58,14 @@ export const getDocumentByAccessToken = async ({ token }: GetDocumentByAccessTok
},
});
if (!result) {
return null;
}
if (result.envelopeItems.length === 0) {
throw new Error('Completed envelope has no items');
}
const firstDocumentData = result.envelopeItems[0].documentData;
if (!firstDocumentData) {
@@ -96,6 +96,7 @@ export const getDocumentAndSenderByToken = async ({
title: true,
order: true,
envelopeId: true,
documentDataId: true,
documentData: true,
},
},
+275 -336
View File
@@ -1,368 +1,307 @@
import type { Prisma, User } from '@prisma/client';
import { DocumentVisibility, EnvelopeType, SigningStatus, TeamMemberRole } from '@prisma/client';
import {
DocumentStatus,
EnvelopeType,
RecipientRole,
SigningStatus,
TeamMemberRole,
} from '@prisma/client';
import type { Expression, ExpressionBuilder, SelectQueryBuilder, SqlBool } from 'kysely';
import { DateTime } from 'luxon';
import { match } from 'ts-pattern';
import type { PeriodSelectorValue } from '@documenso/lib/server-only/document/find-documents';
import { prisma } from '@documenso/prisma';
import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status';
import { kyselyPrisma, prisma, sql } from '@documenso/prisma';
import type { DB } from '@documenso/prisma/generated/types';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { STATS_COUNT_CAP } from '../../constants/document';
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
import { getTeamById } from '../team/get-team';
// 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')),
);
export type GetStatsInput = {
user: Pick<User, 'id' | 'email'>;
team?: Omit<GetTeamCountsOption, 'createdAt'>;
userId: number;
teamId: number;
period?: PeriodSelectorValue;
search?: string;
folderId?: string;
senderIds?: number[];
};
/**
* Builds a capped count from a query builder: wraps it as
* `SELECT COUNT(*) FROM (SELECT id FROM ... LIMIT cap+1) sub`
* and clamps the result to STATS_COUNT_CAP.
*/
const cappedCount = async (qb: EnvelopeQueryBuilder): Promise<number> => {
const result = await kyselyPrisma.$kysely
.selectFrom(
qb
.clearSelect()
.select('Envelope.id')
.limit(STATS_COUNT_CAP + 1)
.as('capped'),
)
.select(({ fn }) => fn.count<number>('id').as('total'))
.executeTakeFirstOrThrow();
return Math.min(Number(result.total ?? 0), STATS_COUNT_CAP);
};
export const getStats = async ({
user,
userId,
teamId,
period,
search = '',
folderId,
...options
senderIds,
}: GetStatsInput) => {
let createdAt: Prisma.EnvelopeWhereInput['createdAt'];
const user = await prisma.user.findFirstOrThrow({
where: { id: userId },
select: { id: true, email: true },
});
if (period) {
const daysAgo = parseInt(period.replace(/d$/, ''), 10);
const team = await getTeamById({ userId, teamId });
const startOfPeriod = DateTime.now().minus({ days: daysAgo }).startOf('day');
const teamEmail = team.teamEmail?.email ?? null;
const currentTeamRole = team.currentTeamRole ?? TeamMemberRole.MEMBER;
const allowedVisibilities = TEAM_DOCUMENT_VISIBILITY_MAP[currentTeamRole];
createdAt = {
gte: startOfPeriod.toJSDate(),
};
}
const searchQuery = search.trim();
const hasSearch = searchQuery.length > 0;
const searchPattern = `%${searchQuery}%`;
const [ownerCounts, notSignedCounts, hasSignedCounts] = await (options.team
? getTeamCounts({
...options.team,
createdAt,
currentUserEmail: user.email,
userId: user.id,
search,
folderId,
})
: getCounts({ user, createdAt, search, folderId }));
// ─── Base query builder ──────────────────────────────────────────────
const stats: Record<ExtendedDocumentStatus, number> = {
[ExtendedDocumentStatus.DRAFT]: 0,
[ExtendedDocumentStatus.PENDING]: 0,
[ExtendedDocumentStatus.COMPLETED]: 0,
[ExtendedDocumentStatus.REJECTED]: 0,
[ExtendedDocumentStatus.INBOX]: 0,
[ExtendedDocumentStatus.ALL]: 0,
const buildBaseQuery = (): EnvelopeQueryBuilder => {
let qb: EnvelopeQueryBuilder = kyselyPrisma.$kysely
.selectFrom('Envelope')
.select('Envelope.id');
// Type = DOCUMENT
qb = qb.where('Envelope.type', '=', sql.lit(EnvelopeType.DOCUMENT));
// Folder filter
qb =
folderId !== undefined
? qb.where('Envelope.folderId', '=', folderId)
: qb.where('Envelope.folderId', 'is', null);
// Period filter
if (period) {
const daysAgo = parseInt(period.replace(/d$/, ''), 10);
const startOfPeriod = DateTime.now().minus({ days: daysAgo }).startOf('day');
qb = qb.where('Envelope.createdAt', '>=', startOfPeriod.toJSDate());
}
// Sender filter
if (senderIds && senderIds.length > 0) {
qb = qb.where('Envelope.userId', 'in', senderIds);
}
// Search filter
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(1000),
),
]),
);
}
return qb;
};
ownerCounts.forEach((stat) => {
stats[stat.status] = stat._count._all;
});
// ─── Shared filter helpers ───────────────────────────────────────────
notSignedCounts.forEach((stat) => {
stats[ExtendedDocumentStatus.INBOX] += stat._count._all;
});
const visibilityFilter = (eb: EnvelopeExpressionBuilder) =>
eb.or([
eb(
'Envelope.visibility',
'in',
allowedVisibilities.map((v) => sql.lit(v)),
),
eb('Envelope.userId', '=', user.id),
recipientExists(eb, user.email),
]);
hasSignedCounts.forEach((stat) => {
if (stat.status === ExtendedDocumentStatus.COMPLETED) {
stats[ExtendedDocumentStatus.COMPLETED] += stat._count._all;
const teamDeletedFilter = (eb: EnvelopeExpressionBuilder) => {
const branches = [
eb.and([eb('Envelope.teamId', '=', team.id), eb('Envelope.deletedAt', 'is', null)]),
];
if (teamEmail) {
branches.push(eb.and([senderEmailIs(eb, teamEmail), eb('Envelope.deletedAt', 'is', null)]));
branches.push(
recipientExists(eb, teamEmail, (reb) => reb('Recipient.documentDeletedAt', 'is', null)),
);
}
if (stat.status === ExtendedDocumentStatus.PENDING) {
stats[ExtendedDocumentStatus.PENDING] += stat._count._all;
}
return eb.or(branches);
};
if (stat.status === ExtendedDocumentStatus.REJECTED) {
stats[ExtendedDocumentStatus.REJECTED] += stat._count._all;
}
});
// ─── Per-status query builders ───────────────────────────────────────
Object.keys(stats).forEach((key) => {
if (key !== ExtendedDocumentStatus.ALL && isExtendedDocumentStatus(key)) {
stats[ExtendedDocumentStatus.ALL] += stats[key];
}
});
// DRAFT: team-owned drafts visible to the user
const draftQuery = buildBaseQuery()
.where('Envelope.status', '=', sql.lit(DocumentStatus.DRAFT))
.where((eb) => {
const accessBranches = [eb('Envelope.teamId', '=', team.id)];
if (teamEmail) {
accessBranches.push(senderEmailIs(eb, teamEmail));
}
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
});
// PENDING: team-owned pending + team-email signed-pending docs
const pendingQuery = buildBaseQuery()
.where('Envelope.status', '=', sql.lit(DocumentStatus.PENDING))
.where((eb) => {
const accessBranches = [eb('Envelope.teamId', '=', team.id)];
if (teamEmail) {
accessBranches.push(senderEmailIs(eb, teamEmail));
accessBranches.push(
recipientExists(eb, teamEmail, (reb) =>
reb.and([
reb('Recipient.signingStatus', '=', sql.lit(SigningStatus.SIGNED)),
reb('Recipient.role', '!=', sql.lit(RecipientRole.CC)),
]),
),
);
}
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
});
// COMPLETED: team-owned completed + team-email received completed
const completedQuery = buildBaseQuery()
.where('Envelope.status', '=', sql.lit(DocumentStatus.COMPLETED))
.where((eb) => {
const accessBranches = [eb('Envelope.teamId', '=', team.id)];
if (teamEmail) {
accessBranches.push(senderEmailIs(eb, teamEmail));
accessBranches.push(recipientExists(eb, teamEmail));
}
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
});
// REJECTED: team-owned rejected + team-email rejected docs
const rejectedQuery = buildBaseQuery()
.where('Envelope.status', '=', sql.lit(DocumentStatus.REJECTED))
.where((eb) => {
const accessBranches = [eb('Envelope.teamId', '=', team.id)];
if (teamEmail) {
accessBranches.push(senderEmailIs(eb, teamEmail));
accessBranches.push(
recipientExists(eb, teamEmail, (reb) =>
reb('Recipient.signingStatus', '=', sql.lit(SigningStatus.REJECTED)),
),
);
}
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
});
// INBOX: non-draft docs where team email is a NOT_SIGNED, non-CC recipient
// Returns 0 if the team has no team email.
const inboxQuery = teamEmail
? buildBaseQuery()
.where('Envelope.status', '!=', sql.lit(DocumentStatus.DRAFT))
.where((eb) =>
eb.and([
visibilityFilter(eb),
recipientExists(eb, teamEmail, (reb) =>
reb.and([
reb('Recipient.documentDeletedAt', 'is', null),
reb('Recipient.signingStatus', '=', sql.lit(SigningStatus.NOT_SIGNED)),
reb('Recipient.role', '!=', sql.lit(RecipientRole.CC)),
]),
),
]),
)
: null;
// ─── Execute all counts in parallel ──────────────────────────────────
const [draft, pending, completed, rejected, inbox] = await Promise.all([
cappedCount(draftQuery),
cappedCount(pendingQuery),
cappedCount(completedQuery),
cappedCount(rejectedQuery),
inboxQuery ? cappedCount(inboxQuery) : Promise.resolve(0),
]);
const all = Math.min(draft + pending + completed + rejected + inbox, STATS_COUNT_CAP);
const stats: Record<ExtendedDocumentStatus, number> = {
[ExtendedDocumentStatus.DRAFT]: draft,
[ExtendedDocumentStatus.PENDING]: pending,
[ExtendedDocumentStatus.COMPLETED]: completed,
[ExtendedDocumentStatus.REJECTED]: rejected,
[ExtendedDocumentStatus.INBOX]: inbox,
[ExtendedDocumentStatus.ALL]: all,
};
return stats;
};
type GetCountsOption = {
user: Pick<User, 'id' | 'email'>;
createdAt: Prisma.EnvelopeWhereInput['createdAt'];
search?: string;
folderId?: string | null;
};
const getCounts = async ({ user, createdAt, search, folderId }: GetCountsOption) => {
const searchFilter: Prisma.EnvelopeWhereInput = {
OR: [
{ title: { contains: search, mode: 'insensitive' } },
{ recipients: { some: { name: { contains: search, mode: 'insensitive' } } } },
{ recipients: { some: { email: { contains: search, mode: 'insensitive' } } } },
],
};
const rootPageFilter = folderId === undefined ? { folderId: null } : {};
return Promise.all([
// Owner counts.
prisma.envelope.groupBy({
by: ['status'],
_count: {
_all: true,
},
where: {
type: EnvelopeType.DOCUMENT,
userId: user.id,
createdAt,
deletedAt: null,
AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}],
},
}),
// Not signed counts.
prisma.envelope.groupBy({
by: ['status'],
_count: {
_all: true,
},
where: {
type: EnvelopeType.DOCUMENT,
status: ExtendedDocumentStatus.PENDING,
recipients: {
some: {
email: user.email,
signingStatus: SigningStatus.NOT_SIGNED,
documentDeletedAt: null,
},
},
createdAt,
AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}],
},
}),
// Has signed counts.
prisma.envelope.groupBy({
by: ['status'],
_count: {
_all: true,
},
where: {
type: EnvelopeType.DOCUMENT,
createdAt,
user: {
email: {
not: user.email,
},
},
OR: [
{
status: ExtendedDocumentStatus.PENDING,
recipients: {
some: {
email: user.email,
signingStatus: SigningStatus.SIGNED,
documentDeletedAt: null,
},
},
},
{
status: ExtendedDocumentStatus.COMPLETED,
recipients: {
some: {
email: user.email,
signingStatus: SigningStatus.SIGNED,
documentDeletedAt: null,
},
},
},
],
AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}],
},
}),
]);
};
type GetTeamCountsOption = {
teamId: number;
teamEmail?: string;
senderIds?: number[];
currentUserEmail: string;
userId: number;
createdAt: Prisma.EnvelopeWhereInput['createdAt'];
currentTeamMemberRole?: TeamMemberRole;
search?: string;
folderId?: string | null;
};
const getTeamCounts = async (options: GetTeamCountsOption) => {
const { createdAt, teamId, teamEmail, folderId } = options;
const senderIds = options.senderIds ?? [];
const userIdWhereClause: Prisma.EnvelopeWhereInput['userId'] =
senderIds.length > 0
? {
in: senderIds,
}
: undefined;
const searchFilter: Prisma.EnvelopeWhereInput = {
OR: [
{ title: { contains: options.search, mode: 'insensitive' } },
{ recipients: { some: { name: { contains: options.search, mode: 'insensitive' } } } },
{ recipients: { some: { email: { contains: options.search, mode: 'insensitive' } } } },
],
};
const rootPageFilter = folderId === undefined ? { folderId: null } : {};
let ownerCountsWhereInput: Prisma.EnvelopeWhereInput = {
type: EnvelopeType.DOCUMENT,
userId: userIdWhereClause,
createdAt,
teamId,
deletedAt: null,
};
let notSignedCountsGroupByArgs = null;
let hasSignedCountsGroupByArgs = null;
const visibilityFiltersWhereInput: Prisma.EnvelopeWhereInput = {
AND: [
{ deletedAt: null },
{
OR: [
match(options.currentTeamMemberRole)
.with(TeamMemberRole.ADMIN, () => ({
visibility: {
in: [
DocumentVisibility.EVERYONE,
DocumentVisibility.MANAGER_AND_ABOVE,
DocumentVisibility.ADMIN,
],
},
}))
.with(TeamMemberRole.MANAGER, () => ({
visibility: {
in: [DocumentVisibility.EVERYONE, DocumentVisibility.MANAGER_AND_ABOVE],
},
}))
.otherwise(() => ({
visibility: {
equals: DocumentVisibility.EVERYONE,
},
})),
{
OR: [
{ userId: options.userId },
{ recipients: { some: { email: options.currentUserEmail } } },
],
},
],
},
],
};
ownerCountsWhereInput = {
...ownerCountsWhereInput,
AND: [
...(Array.isArray(visibilityFiltersWhereInput.AND)
? visibilityFiltersWhereInput.AND
: visibilityFiltersWhereInput.AND
? [visibilityFiltersWhereInput.AND]
: []),
searchFilter,
rootPageFilter,
folderId ? { folderId } : {},
],
};
if (teamEmail) {
ownerCountsWhereInput = {
type: EnvelopeType.DOCUMENT,
userId: userIdWhereClause,
createdAt,
OR: [
{
teamId,
},
{
user: {
email: teamEmail,
},
},
],
deletedAt: null,
AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}],
};
notSignedCountsGroupByArgs = {
by: ['status'],
_count: {
_all: true,
},
where: {
type: EnvelopeType.DOCUMENT,
userId: userIdWhereClause,
createdAt,
status: ExtendedDocumentStatus.PENDING,
recipients: {
some: {
email: teamEmail,
signingStatus: SigningStatus.NOT_SIGNED,
documentDeletedAt: null,
},
},
deletedAt: null,
AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}],
},
} satisfies Prisma.EnvelopeGroupByArgs;
hasSignedCountsGroupByArgs = {
by: ['status'],
_count: {
_all: true,
},
where: {
type: EnvelopeType.DOCUMENT,
userId: userIdWhereClause,
createdAt,
OR: [
{
status: ExtendedDocumentStatus.PENDING,
recipients: {
some: {
email: teamEmail,
signingStatus: SigningStatus.SIGNED,
documentDeletedAt: null,
},
},
deletedAt: null,
},
{
status: ExtendedDocumentStatus.COMPLETED,
recipients: {
some: {
email: teamEmail,
signingStatus: SigningStatus.SIGNED,
documentDeletedAt: null,
},
},
},
],
AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}],
},
} satisfies Prisma.EnvelopeGroupByArgs;
}
return Promise.all([
prisma.envelope.groupBy({
by: ['status'],
_count: {
_all: true,
},
where: ownerCountsWhereInput,
}),
notSignedCountsGroupByArgs ? prisma.envelope.groupBy(notSignedCountsGroupByArgs) : [],
hasSignedCountsGroupByArgs ? prisma.envelope.groupBy(hasSignedCountsGroupByArgs) : [],
]);
};
@@ -1,4 +1,4 @@
import { EnvelopeType, SigningStatus } from '@prisma/client';
import { DocumentStatus, EnvelopeType, SigningStatus } from '@prisma/client';
import { jobs } from '@documenso/lib/jobs/client';
import { prisma } from '@documenso/prisma';
@@ -9,6 +9,7 @@ import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { mapSecondaryIdToDocumentId, unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
import { assertRecipientNotExpired } from '../../utils/recipients';
export type RejectDocumentWithTokenOptions = {
token: string;
@@ -42,6 +43,14 @@ export async function rejectDocumentWithToken({
});
}
if (envelope.status !== DocumentStatus.PENDING) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `Document ${envelope.id} must be pending to reject`,
});
}
assertRecipientNotExpired(recipient);
// Update the recipient status to rejected
const [updatedRecipient] = await prisma.$transaction([
prisma.recipient.update({
@@ -7,10 +7,12 @@ import {
OrganisationType,
RecipientRole,
SigningStatus,
WebhookTriggerEvents,
} from '@prisma/client';
import { mailer } from '@documenso/email/mailer';
import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite';
import { resolveExpiresAt } from '@documenso/lib/constants/envelope-expiration';
import {
RECIPIENT_ROLES_DESCRIPTION,
RECIPIENT_ROLE_TO_EMAIL_TYPE,
@@ -24,12 +26,17 @@ import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import {
ZWebhookDocumentSchema,
mapEnvelopeToWebhookDocumentPayload,
} from '../../types/webhook-payload';
import { isDocumentCompleted } from '../../utils/document';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { isRecipientEmailValidForSending } from '../../utils/recipients';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { getEmailContext } from '../email/get-email-context';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export type ResendDocumentOptions = {
id: EnvelopeIdOptions;
@@ -94,11 +101,31 @@ export const resendDocument = async ({
throw new Error('Can not send completed document');
}
// Refresh the expiresAt on each resent recipient.
const expiresAt = resolveExpiresAt(envelope.documentMeta?.envelopeExpirationPeriod ?? null);
const recipientsToRemind = envelope.recipients.filter(
(recipient) =>
recipients.includes(recipient.id) && recipient.signingStatus === SigningStatus.NOT_SIGNED,
recipients.includes(recipient.id) &&
recipient.signingStatus === SigningStatus.NOT_SIGNED &&
recipient.role !== RecipientRole.CC,
);
// Extend the expiration deadline for recipients being resent.
if (expiresAt && recipientsToRemind.length > 0) {
await prisma.recipient.updateMany({
where: {
id: {
in: recipientsToRemind.map((r) => r.id),
},
},
data: {
expiresAt,
expirationNotifiedAt: null,
},
});
}
const isRecipientSigningRequestEmailEnabled = extractDerivedDocumentEmailSettings(
envelope.documentMeta,
).recipientSigningRequest;
@@ -192,45 +219,49 @@ export const resendDocument = async ({
}),
]);
await prisma.$transaction(
async (tx) => {
await mailer.sendMail({
to: {
address: email,
name,
},
from: senderEmail,
replyTo: replyToEmail,
subject: envelope.documentMeta.subject
? renderCustomEmailTemplate(
i18n._(msg`Reminder: ${envelope.documentMeta.subject}`),
customEmailTemplate,
)
: emailSubject,
html,
text,
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
envelopeId: envelope.id,
metadata: requestMetadata,
data: {
emailType: recipientEmailType,
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientRole: recipient.role,
recipientId: recipient.id,
isResending: true,
},
}),
});
// Send email outside any transaction to avoid holding a connection
// open during network I/O.
await mailer.sendMail({
to: {
address: email,
name,
},
{ timeout: 30_000 },
);
from: senderEmail,
replyTo: replyToEmail,
subject: envelope.documentMeta.subject
? renderCustomEmailTemplate(
i18n._(msg`Reminder: ${envelope.documentMeta.subject}`),
customEmailTemplate,
)
: emailSubject,
html,
text,
});
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
envelopeId: envelope.id,
metadata: requestMetadata,
data: {
emailType: recipientEmailType,
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientRole: recipient.role,
recipientId: recipient.id,
isResending: true,
},
}),
});
}),
);
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_REMINDER_SENT,
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelope)),
userId: envelope.userId,
teamId: envelope.teamId,
});
return envelope;
};
@@ -1,16 +1,12 @@
import { DocumentStatus, EnvelopeType } from '@prisma/client';
import type { Envelope, Recipient, User } from '@prisma/client';
import { DocumentVisibility, TeamMemberRole } from '@prisma/client';
import type { Prisma } from '@prisma/client';
import { DocumentStatus, DocumentVisibility, EnvelopeType, TeamMemberRole } from '@prisma/client';
import { match } from 'ts-pattern';
import {
buildTeamWhereQuery,
formatDocumentsPath,
getHighestTeamRoleInGroup,
} from '@documenso/lib/utils/teams';
import { formatDocumentsPath, getHighestTeamRoleInGroup } from '@documenso/lib/utils/teams';
import { prisma } from '@documenso/prisma';
import { mapSecondaryIdToDocumentId } from '../../utils/envelope';
import { getUserTeamGroups } from '../team/get-user-team-groups';
export type SearchDocumentsWithKeywordOptions = {
query: string;
@@ -23,105 +19,83 @@ export const searchDocumentsWithKeyword = async ({
userId,
limit = 20,
}: SearchDocumentsWithKeywordOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
if (!query.trim()) {
return [];
}
const [user, teamGroupsByTeamId] = await Promise.all([
prisma.user.findFirstOrThrow({
where: {
id: userId,
},
}),
getUserTeamGroups({ userId }),
]);
const teamIds = [...teamGroupsByTeamId.keys()];
const filters: Prisma.EnvelopeWhereInput[] = [
// Documents owned by the user matching title, externalId, or recipient email.
{
userId,
deletedAt: null,
OR: [
{ title: { contains: query, mode: 'insensitive' } },
{ externalId: { contains: query, mode: 'insensitive' } },
{
recipients: {
some: { email: { contains: query, mode: 'insensitive' } },
},
},
],
},
});
// Documents where the user is a recipient (completed or pending).
{
status: { in: [DocumentStatus.COMPLETED, DocumentStatus.PENDING] },
recipients: { some: { email: user.email } },
title: { contains: query, mode: 'insensitive' },
deletedAt: null,
},
];
// Team documents the user has access to.
if (teamIds.length > 0) {
filters.push({
teamId: { in: teamIds },
deletedAt: null,
OR: [
{ title: { contains: query, mode: 'insensitive' } },
{ externalId: { contains: query, mode: 'insensitive' } },
{
recipients: {
some: { email: { contains: query, mode: 'insensitive' } },
},
},
],
});
}
const envelopes = await prisma.envelope.findMany({
where: {
type: EnvelopeType.DOCUMENT,
OR: [
{
title: {
contains: query,
mode: 'insensitive',
},
userId: userId,
deletedAt: null,
},
{
externalId: {
contains: query,
mode: 'insensitive',
},
userId: userId,
deletedAt: null,
},
{
recipients: {
some: {
email: {
contains: query,
mode: 'insensitive',
},
},
},
userId: userId,
deletedAt: null,
},
{
status: DocumentStatus.COMPLETED,
recipients: {
some: {
email: user.email,
},
},
title: {
contains: query,
mode: 'insensitive',
},
},
{
status: DocumentStatus.PENDING,
recipients: {
some: {
email: user.email,
},
},
title: {
contains: query,
mode: 'insensitive',
},
deletedAt: null,
},
{
title: {
contains: query,
mode: 'insensitive',
},
team: buildTeamWhereQuery({ teamId: undefined, userId }),
deletedAt: null,
},
{
externalId: {
contains: query,
mode: 'insensitive',
},
team: buildTeamWhereQuery({ teamId: undefined, userId }),
deletedAt: null,
},
],
OR: filters,
},
include: {
recipients: true,
select: {
id: true,
userId: true,
teamId: true,
title: true,
secondaryId: true,
visibility: true,
recipients: {
select: {
email: true,
token: true,
},
},
team: {
select: {
url: true,
teamGroups: {
where: {
organisationGroup: {
organisationGroupMembers: {
some: {
organisationMember: {
userId,
},
},
},
},
},
},
},
},
},
@@ -129,29 +103,24 @@ export const searchDocumentsWithKeyword = async ({
orderBy: {
createdAt: 'desc',
},
take: limit,
// Over-fetch to compensate for post-query visibility filtering on team documents.
take: limit * 3,
});
const isOwner = (envelope: Envelope, user: User) => envelope.userId === user.id;
const getSigningLink = (recipients: Recipient[], user: User) =>
`/sign/${recipients.find((r) => r.email === user.email)?.token}`;
const maskedDocuments = envelopes
const results = envelopes
.filter((envelope) => {
if (!envelope.teamId || isOwner(envelope, user)) {
if (!envelope.teamId || envelope.userId === user.id) {
return true;
}
const teamMemberRole = getHighestTeamRoleInGroup(
envelope.team.teamGroups.filter((tg) => tg.teamId === envelope.teamId),
);
const teamGroups = teamGroupsByTeamId.get(envelope.teamId) ?? [];
const teamMemberRole = getHighestTeamRoleInGroup(teamGroups);
if (!teamMemberRole) {
return false;
}
const canAccessDocument = match([envelope.visibility, teamMemberRole])
return match([envelope.visibility, teamMemberRole])
.with([DocumentVisibility.EVERYONE, TeamMemberRole.ADMIN], () => true)
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MANAGER], () => true)
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MEMBER], () => true)
@@ -159,34 +128,29 @@ export const searchDocumentsWithKeyword = async ({
.with([DocumentVisibility.MANAGER_AND_ABOVE, TeamMemberRole.MANAGER], () => true)
.with([DocumentVisibility.ADMIN, TeamMemberRole.ADMIN], () => true)
.otherwise(() => false);
return canAccessDocument;
})
.slice(0, limit)
.map((envelope) => {
const { recipients, ...documentWithoutRecipient } = envelope;
let documentPath;
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
if (isOwner(envelope, user)) {
documentPath = `${formatDocumentsPath(envelope.team.url)}/${legacyDocumentId}`;
} else if (envelope.teamId && envelope.team.teamGroups.length > 0) {
documentPath = `${formatDocumentsPath(envelope.team.url)}/${legacyDocumentId}`;
let path: string;
if (
envelope.userId === user.id ||
(envelope.teamId && teamGroupsByTeamId.has(envelope.teamId))
) {
path = `${formatDocumentsPath(envelope.team.url)}/${legacyDocumentId}`;
} else {
documentPath = getSigningLink(recipients, user);
const signingToken = envelope.recipients.find((r) => r.email === user.email)?.token;
path = `/sign/${signingToken}`;
}
return {
...documentWithoutRecipient,
team: {
id: envelope.teamId,
url: envelope.team.url,
},
path: documentPath,
title: envelope.title,
path,
value: [envelope.id, envelope.title, ...envelope.recipients.map((r) => r.email)].join(' '),
};
});
return maskedDocuments;
return results;
};
@@ -1,4 +1,4 @@
import type { DocumentData, Envelope, EnvelopeItem, Field } from '@prisma/client';
import type { DocumentData, Envelope, EnvelopeItem, Field, Recipient } from '@prisma/client';
import {
DocumentSigningOrder,
DocumentStatus,
@@ -10,6 +10,7 @@ import {
WebhookTriggerEvents,
} from '@prisma/client';
import { resolveExpiresAt } from '@documenso/lib/constants/envelope-expiration';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
@@ -17,6 +18,7 @@ import { prisma } from '@documenso/prisma';
import { checkboxValidationSigns } from '@documenso/ui/primitives/document-flow/field-items-advanced-settings/constants';
import { validateCheckboxLength } from '../../advanced-fields-validation/validate-checkbox';
import { DIRECT_TEMPLATE_RECIPIENT_EMAIL } from '../../constants/direct-templates';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { jobs } from '../../jobs/client';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
@@ -38,7 +40,10 @@ import { isDocumentCompleted } from '../../utils/document';
import { extractDocumentAuthMethods } from '../../utils/document-auth';
import { type EnvelopeIdOptions, mapSecondaryIdToDocumentId } from '../../utils/envelope';
import { toCheckboxCustomText, toRadioCustomText } from '../../utils/fields';
import { isRecipientEmailValidForSending } from '../../utils/recipients';
import {
getRecipientsWithMissingFields,
isRecipientEmailValidForSending,
} from '../../utils/recipients';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
@@ -150,30 +155,21 @@ export const sendDocument = async ({
}
});
// Commented out server side checks for minimum 1 signature per signer now since we need to
// decide if we want to enforce this for API & templates.
// const fields = await getFieldsForDocument({
// documentId: documentId,
// userId: userId,
// });
// Validate that recipients who require fields (e.g., signers need signature fields) have them.
const recipientsWithMissingFields = getRecipientsWithMissingFields(
envelope.recipients,
envelope.fields,
);
// const fieldsWithSignerEmail = fields.map((field) => ({
// ...field,
// signerEmail:
// envelope.Recipient.find((recipient) => recipient.id === field.recipientId)?.email ?? '',
// }));
if (recipientsWithMissingFields.length > 0) {
const missingRecipientDescriptions = recipientsWithMissingFields
.map((r) => (r.name ? `${r.name} (${r.email}, id: ${r.id})` : `${r.email} (id: ${r.id})`))
.join(', ');
// const everySignerHasSignature = document?.Recipient.every(
// (recipient) =>
// recipient.role !== RecipientRole.SIGNER ||
// fieldsWithSignerEmail.some(
// (field) => field.type === 'SIGNATURE' && field.signerEmail === recipient.email,
// ),
// );
// if (!everySignerHasSignature) {
// throw new Error('Some signers have not been assigned a signature field.');
// }
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `The following recipients are missing required fields: ${missingRecipientDescriptions}. Signers must have at least one signature field.`,
});
}
const allRecipientsHaveNoActionToTake = envelope.recipients.every(
(recipient) =>
@@ -214,7 +210,7 @@ export const sendDocument = async ({
});
}
const fieldToAutoInsert = extractFieldAutoInsertValues(unknownField);
const fieldToAutoInsert = extractFieldAutoInsertValues(unknownField, recipient);
// Only auto-insert fields if the recipient has not been sent the document yet.
if (fieldToAutoInsert && recipient.sendStatus !== SendStatus.SENT) {
@@ -267,6 +263,28 @@ export const sendDocument = async ({
});
}
const expiresAt = resolveExpiresAt(envelope.documentMeta?.envelopeExpirationPeriod ?? null);
// Set expiresAt on each recipient that hasn't already signed/rejected.
// Exclude CC recipients since they don't sign and shouldn't be subject to expiry.
if (expiresAt) {
await tx.recipient.updateMany({
where: {
envelopeId: envelope.id,
signingStatus: {
notIn: [SigningStatus.SIGNED, SigningStatus.REJECTED],
},
role: {
not: RecipientRole.CC,
},
},
data: {
expiresAt,
expirationNotifiedAt: null,
},
});
}
return await tx.envelope.update({
where: {
id: envelope.id,
@@ -361,6 +379,7 @@ const injectFormValuesIntoDocument = async (
*/
export const extractFieldAutoInsertValues = (
unknownField: Field,
recipient: Pick<Recipient, 'email'>,
): { fieldId: number; customText: string } | null => {
const parsedField = ZFieldAndMetaSchema.safeParse(unknownField);
@@ -373,6 +392,18 @@ export const extractFieldAutoInsertValues = (
const field = parsedField.data;
const fieldId = unknownField.id;
// Auto insert email fields if the recipient has a valid email.
if (
field.type === FieldType.EMAIL &&
isRecipientEmailValidForSending(recipient) &&
recipient.email !== DIRECT_TEMPLATE_RECIPIENT_EMAIL
) {
return {
fieldId,
customText: recipient.email,
};
}
// Auto insert text fields with prefilled values.
if (field.type === FieldType.TEXT) {
const { text } = ZTextFieldMeta.parse(field.fieldMeta);
@@ -1,5 +1,4 @@
import { EnvelopeType, ReadStatus, SendStatus } from '@prisma/client';
import { WebhookTriggerEvents } from '@prisma/client';
import { EnvelopeType, ReadStatus, SendStatus, WebhookTriggerEvents } from '@prisma/client';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
@@ -70,6 +69,8 @@ export const viewedDocument = async ({
// This handles cases where distribution is done manually
sendStatus: SendStatus.SENT,
readStatus: ReadStatus.OPENED,
// Only set sentAt if not already set (email may have been sent before they opened).
...(!recipient.sentAt ? { sentAt: new Date() } : {}),
},
});
@@ -93,6 +94,9 @@ export const viewedDocument = async ({
});
});
// Don't schedule reminders for manually distributed documents —
// there's no email pathway to send them through.
const envelope = await prisma.envelope.findUniqueOrThrow({
where: {
id: recipient.envelopeId,