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
+2 -6
View File
@@ -1,17 +1,13 @@
import { prisma } from '@documenso/prisma';
import type { User } from '@prisma/client';
import { UserSecurityAuditLogType } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { validateTwoFactorAuthentication } from './validate-2fa';
type DisableTwoFactorAuthenticationOptions = {
user: Pick<
User,
'id' | 'email' | 'twoFactorEnabled' | 'twoFactorSecret' | 'twoFactorBackupCodes'
>;
user: Pick<User, 'id' | 'email' | 'twoFactorEnabled' | 'twoFactorSecret' | 'twoFactorBackupCodes'>;
totpCode?: string;
backupCode?: string;
requestMetadata?: RequestMetadata;
@@ -1,12 +1,10 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import { EnvelopeType } from '@prisma/client';
import { mailer } from '@documenso/email/mailer';
import { AccessAuth2FAEmailTemplate } from '@documenso/email/templates/access-auth-2fa';
import { isRecipientEmailValidForSending } from '@documenso/lib/utils/recipients';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/core/macro';
import { EnvelopeType } from '@prisma/client';
import { createElement } from 'react';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
@@ -108,32 +106,29 @@ export const send2FATokenEmail = async ({ token, envelopeId }: Send2FATokenEmail
renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }),
]);
await prisma.$transaction(
async (tx) => {
await mailer.sendMail({
to: {
address: recipient.email,
name: recipient.name,
},
from: senderEmail,
replyTo: replyToEmail,
subject,
html,
text,
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_REQUESTED,
envelopeId: envelope.id,
data: {
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientId: recipient.id,
},
}),
});
// Send email outside any transaction to avoid holding a connection
// open during network I/O.
await mailer.sendMail({
to: {
address: recipient.email,
name: recipient.name,
},
{ timeout: 30_000 },
);
from: senderEmail,
replyTo: replyToEmail,
subject,
html,
text,
});
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_REQUESTED,
envelopeId: envelope.id,
data: {
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientId: recipient.id,
},
}),
});
};
+1 -2
View File
@@ -1,6 +1,5 @@
import { type User, UserSecurityAuditLogType } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { type User, UserSecurityAuditLogType } from '@prisma/client';
import { AppError } from '../../errors/app-error';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
@@ -25,9 +25,7 @@ export const getBackupCodes = ({ user }: GetBackupCodesOptions) => {
throw new Error('User has no backup codes');
}
const secret = Buffer.from(symmetricDecrypt({ key, data: user.twoFactorBackupCodes })).toString(
'utf-8',
);
const secret = Buffer.from(symmetricDecrypt({ key, data: user.twoFactorBackupCodes })).toString('utf-8');
const data = JSON.parse(secret);
@@ -6,8 +6,6 @@ type IsTwoFactorAuthenticationEnabledOptions = {
user: User;
};
export const isTwoFactorAuthenticationEnabled = ({
user,
}: IsTwoFactorAuthenticationEnabledOptions) => {
export const isTwoFactorAuthenticationEnabled = ({ user }: IsTwoFactorAuthenticationEnabledOptions) => {
return user.twoFactorEnabled && typeof DOCUMENSO_ENCRYPTION_KEY === 'string';
};
+3 -6
View File
@@ -1,10 +1,9 @@
import { type User } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import type { User } from '@prisma/client';
import { base32 } from '@scure/base';
import crypto from 'crypto';
import { createTOTPKeyURI } from 'oslo/otp';
import { prisma } from '@documenso/prisma';
import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto';
import { symmetricEncrypt } from '../../universal/crypto';
@@ -14,9 +13,7 @@ type SetupTwoFactorAuthenticationOptions = {
const ISSUER = 'Documenso';
export const setupTwoFactorAuthentication = async ({
user,
}: SetupTwoFactorAuthenticationOptions) => {
export const setupTwoFactorAuthentication = async ({ user }: SetupTwoFactorAuthenticationOptions) => {
const key = DOCUMENSO_ENCRYPTION_KEY;
if (!key) {
+1 -4
View File
@@ -7,10 +7,7 @@ import { verifyBackupCode } from './verify-backup-code';
type ValidateTwoFactorAuthenticationOptions = {
totpCode?: string;
backupCode?: string;
user: Pick<
User,
'id' | 'email' | 'twoFactorEnabled' | 'twoFactorSecret' | 'twoFactorBackupCodes'
>;
user: Pick<User, 'id' | 'email' | 'twoFactorEnabled' | 'twoFactorSecret' | 'twoFactorBackupCodes'>;
};
export const validateTwoFactorAuthentication = async ({
@@ -30,9 +30,7 @@ export const verifyTwoFactorAuthenticationToken = async ({
throw new Error('user missing 2fa secret');
}
const secret = Buffer.from(symmetricDecrypt({ key, data: user.twoFactorSecret })).toString(
'utf-8',
);
const secret = Buffer.from(symmetricDecrypt({ key, data: user.twoFactorSecret })).toString('utf-8');
const decodedSecret = base32.decode(secret);
@@ -1,6 +1,5 @@
import { compare } from '@node-rs/bcrypt';
import { prisma } from '@documenso/prisma';
import { compare } from '@node-rs/bcrypt';
type VerifyPasswordOptions = {
userId: number;
@@ -5,10 +5,7 @@ import { getBackupCodes } from './get-backup-code';
import { validateTwoFactorAuthentication } from './validate-2fa';
type ViewBackupCodesOptions = {
user: Pick<
User,
'id' | 'email' | 'twoFactorEnabled' | 'twoFactorSecret' | 'twoFactorBackupCodes'
>;
user: Pick<User, 'id' | 'email' | 'twoFactorEnabled' | 'twoFactorSecret' | 'twoFactorBackupCodes'>;
token: string;
};
@@ -1,6 +1,5 @@
import { EnvelopeType, type Prisma } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { EnvelopeType, type Prisma } from '@prisma/client';
import type { FindResultResponse } from '../../types/search-params';
@@ -10,11 +9,7 @@ export interface AdminFindDocumentsOptions {
perPage?: number;
}
export const adminFindDocuments = async ({
query,
page = 1,
perPage = 10,
}: AdminFindDocumentsOptions) => {
export const adminFindDocuments = async ({ query, page = 1, perPage = 10 }: AdminFindDocumentsOptions) => {
let termFilters: Prisma.EnvelopeWhereInput | undefined = !query
? undefined
: {
@@ -0,0 +1,99 @@
import { kyselyPrisma, sql } from '@documenso/prisma';
import { DocumentStatus, EnvelopeType, RecipientRole, SigningStatus } from '@prisma/client';
import type { FindResultResponse } from '../../types/search-params';
export type AdminUnsealedDocument = {
id: string;
secondaryId: string;
title: string;
status: string;
createdAt: Date;
updatedAt: Date;
userId: number;
teamId: number;
ownerName: string | null;
ownerEmail: string;
lastSignedAt: Date | null;
};
export type AdminFindUnsealedDocumentsOptions = {
page?: number;
perPage?: number;
};
export const adminFindUnsealedDocuments = async ({
page = 1,
perPage = 20,
}: AdminFindUnsealedDocumentsOptions): Promise<FindResultResponse<AdminUnsealedDocument[]>> => {
const offset = Math.max(page - 1, 0) * perPage;
const baseQuery = kyselyPrisma.$kysely
.selectFrom('Envelope')
.where('Envelope.status', '=', sql.lit(DocumentStatus.PENDING))
.where('Envelope.type', '=', sql.lit(EnvelopeType.DOCUMENT))
.where('Envelope.deletedAt', 'is', null)
// Must have at least one recipient.
.where((eb) => eb.exists(eb.selectFrom('Recipient').whereRef('Recipient.envelopeId', '=', 'Envelope.id')))
// Document is ready to seal: all recipients are SIGNED/CC, or any recipient REJECTED.
.where((eb) =>
eb.or([
// Case 1: All recipients are either SIGNED or CC.
eb.not(
eb.exists(
eb
.selectFrom('Recipient')
.whereRef('Recipient.envelopeId', '=', 'Envelope.id')
.where('Recipient.signingStatus', '!=', sql.lit(SigningStatus.SIGNED))
.where('Recipient.role', '!=', sql.lit(RecipientRole.CC)),
),
),
// Case 2: Any recipient has rejected.
eb.exists(
eb
.selectFrom('Recipient')
.whereRef('Recipient.envelopeId', '=', 'Envelope.id')
.where('Recipient.signingStatus', '=', sql.lit(SigningStatus.REJECTED)),
),
]),
);
const [data, countResult] = await Promise.all([
baseQuery
.innerJoin('User', 'User.id', 'Envelope.userId')
.select([
'Envelope.id',
'Envelope.secondaryId',
'Envelope.title',
'Envelope.status',
'Envelope.createdAt',
'Envelope.updatedAt',
'Envelope.userId',
'Envelope.teamId',
'User.name as ownerName',
'User.email as ownerEmail',
])
.select((eb) =>
eb
.selectFrom('Recipient')
.whereRef('Recipient.envelopeId', '=', 'Envelope.id')
.select(sql<Date>`max("Recipient"."signedAt")`.as('lastSignedAt'))
.as('lastSignedAt'),
)
.orderBy('Envelope.createdAt', 'desc')
.limit(perPage)
.offset(offset)
.execute(),
baseQuery.select(({ fn }) => [fn.countAll().as('count')]).execute(),
]);
const count = Number(countResult[0]?.count ?? 0);
return {
data: data as unknown as AdminUnsealedDocument[],
count,
currentPage: Math.max(page, 1),
perPage,
totalPages: Math.ceil(count / perPage),
};
};
@@ -1,11 +1,9 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import { DocumentStatus, SendStatus } from '@prisma/client';
import { mailer } from '@documenso/email/mailer';
import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/core/macro';
import { DocumentStatus, SendStatus } from '@prisma/client';
import { createElement } from 'react';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
@@ -23,10 +21,7 @@ export type AdminSuperDeleteDocumentOptions = {
requestMetadata?: RequestMetadata;
};
export const adminSuperDeleteDocument = async ({
envelopeId,
requestMetadata,
}: AdminSuperDeleteDocumentOptions) => {
export const adminSuperDeleteDocument = async ({ envelopeId, requestMetadata }: AdminSuperDeleteDocumentOptions) => {
const envelope = await prisma.envelope.findUnique({
where: {
id: envelopeId,
@@ -61,20 +56,12 @@ export const adminSuperDeleteDocument = async ({
const { status, user } = envelope;
const isDocumentDeletedEmailEnabled = extractDerivedDocumentEmailSettings(
envelope.documentMeta,
).documentDeleted;
const isDocumentDeletedEmailEnabled = extractDerivedDocumentEmailSettings(envelope.documentMeta).documentDeleted;
const recipientsToNotify = envelope.recipients.filter((recipient) =>
isRecipientEmailValidForSending(recipient),
);
const recipientsToNotify = envelope.recipients.filter((recipient) => isRecipientEmailValidForSending(recipient));
// if the document is pending, send cancellation emails to all recipients
if (
status === DocumentStatus.PENDING &&
recipientsToNotify.length > 0 &&
isDocumentDeletedEmailEnabled
) {
if (status === DocumentStatus.PENDING && recipientsToNotify.length > 0 && isDocumentDeletedEmailEnabled) {
await Promise.all(
recipientsToNotify.map(async (recipient) => {
if (recipient.sendStatus !== SendStatus.SENT) {
@@ -1,7 +1,6 @@
import { EnvelopeType } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { EnvelopeType } from '@prisma/client';
export const getDocumentStats = async () => {
const counts = await prisma.envelope.groupBy({
@@ -1,6 +1,5 @@
import type { EnvelopeType } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import type { EnvelopeType } from '@prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { EnvelopeIdOptions } from '../../utils/envelope';
@@ -1,8 +1,7 @@
import type { DocumentStatus } from '@prisma/client';
import { EnvelopeType } from '@prisma/client';
import type { DateRange } from '@documenso/lib/types/search-params';
import { kyselyPrisma, sql } from '@documenso/prisma';
import type { DocumentStatus } from '@prisma/client';
import { EnvelopeType } from '@prisma/client';
export type OrganisationSummary = {
totalTeams: number;
@@ -260,10 +259,7 @@ async function getDocumentInsights(
countQuery = countQuery.select(({ fn }) => [fn.countAll().as('count')]);
const [documents, countResult] = await Promise.all([
documentsQuery.execute(),
countQuery.execute(),
]);
const [documents, countResult] = await Promise.all([documentsQuery.execute(), countQuery.execute()]);
const count = Number((countResult[0] as { count: number })?.count || 0);
@@ -302,9 +298,7 @@ async function getOrganisationSummary(
.where('e.type', '=', sql.lit(EnvelopeType.DOCUMENT))
.select([
sql<number>`count(e.id)`.as('totalDocuments'),
sql<number>`count(case when e.status in ('DRAFT', 'PENDING') then 1 end)`.as(
'activeDocuments',
),
sql<number>`count(case when e.status in ('DRAFT', 'PENDING') then 1 end)`.as('activeDocuments'),
sql<number>`count(case when e.status = 'COMPLETED' then 1 end)`.as('completedDocuments'),
sql<number>`count(case when e.status = 'COMPLETED' then 1 end)`.as('volumeAllTime'),
(createdAtFrom
@@ -1,6 +1,5 @@
import { ReadStatus, SendStatus, SigningStatus } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { ReadStatus, SendStatus, SigningStatus } from '@prisma/client';
export const getRecipientsStats = async () => {
const results = await prisma.recipient.groupBy({
@@ -1,7 +1,6 @@
import { DocumentStatus, EnvelopeType } from '@prisma/client';
import type { DateRange } from '@documenso/lib/types/search-params';
import { kyselyPrisma, sql } from '@documenso/prisma';
import { DocumentStatus, EnvelopeType } from '@prisma/client';
export type OrganisationInsights = {
id: number;
@@ -37,10 +36,7 @@ export async function getSigningVolume({
eb.or([
eb('o.name', 'ilike', `%${search}%`),
eb.exists(
eb
.selectFrom('Team as t')
.whereRef('t.organisationId', '=', 'o.id')
.where('t.name', 'ilike', `%${search}%`),
eb.selectFrom('Team as t').whereRef('t.organisationId', '=', 'o.id').where('t.name', 'ilike', `%${search}%`),
),
]),
)
@@ -82,10 +78,7 @@ export async function getSigningVolume({
eb.or([
eb('o.name', 'ilike', `%${search}%`),
eb.exists(
eb
.selectFrom('Team as t')
.whereRef('t.organisationId', '=', 'o.id')
.where('t.name', 'ilike', `%${search}%`),
eb.selectFrom('Team as t').whereRef('t.organisationId', '=', 'o.id').where('t.name', 'ilike', `%${search}%`),
),
]),
)
@@ -153,10 +146,7 @@ export async function getOrganisationInsights({
eb.or([
eb('o.name', 'ilike', `%${search}%`),
eb.exists(
eb
.selectFrom('Team as t')
.whereRef('t.organisationId', '=', 'o.id')
.where('t.name', 'ilike', `%${search}%`),
eb.selectFrom('Team as t').whereRef('t.organisationId', '=', 'o.id').where('t.name', 'ilike', `%${search}%`),
),
]),
)
@@ -165,9 +155,7 @@ export async function getOrganisationInsights({
'o.createdAt as createdAt',
'o.customerId as customerId',
sql<string>`COALESCE(o.name, 'Unknown')`.as('name'),
sql<string>`CASE WHEN s.status IS NOT NULL THEN s.status ELSE NULL END`.as(
'subscriptionStatus',
),
sql<string>`CASE WHEN s.status IS NOT NULL THEN s.status ELSE NULL END`.as('subscriptionStatus'),
eb
.selectFrom('Team as t')
.whereRef('t.organisationId', '=', 'o.id')
@@ -212,10 +200,7 @@ export async function getOrganisationInsights({
eb.or([
eb('o.name', 'ilike', `%${search}%`),
eb.exists(
eb
.selectFrom('Team as t')
.whereRef('t.organisationId', '=', 'o.id')
.where('t.name', 'ilike', `%${search}%`),
eb.selectFrom('Team as t').whereRef('t.organisationId', '=', 'o.id').where('t.name', 'ilike', `%${search}%`),
),
]),
)
@@ -1,7 +1,6 @@
import { DateTime } from 'luxon';
import { kyselyPrisma, prisma, sql } from '@documenso/prisma';
import { SubscriptionStatus, UserSecurityAuditLogType } from '@documenso/prisma/client';
import { DateTime } from 'luxon';
export const getUsersCount = async () => {
return await prisma.user.count();
@@ -1,14 +1,14 @@
import { SigningStatus } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { type RecipientRole, SigningStatus } from '@prisma/client';
export type UpdateRecipientOptions = {
id: number;
name: string | undefined;
email: string | undefined;
role: RecipientRole | undefined;
};
export const updateRecipient = async ({ id, name, email }: UpdateRecipientOptions) => {
export const updateRecipient = async ({ id, name, email, role }: UpdateRecipientOptions) => {
const recipient = await prisma.recipient.findFirstOrThrow({
where: {
id,
@@ -26,6 +26,7 @@ export const updateRecipient = async ({ id, name, email }: UpdateRecipientOption
data: {
name,
email,
role,
},
});
};
@@ -1,6 +1,5 @@
import type { Role } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import type { Role } from '@prisma/client';
export type UpdateUserOptions = {
id: number;
@@ -1,10 +1,9 @@
import { prisma } from '@documenso/prisma';
import { createCanvas, loadImage } from '@napi-rs/canvas';
import { DocumentStatus, type Field, RecipientRole } from '@prisma/client';
import { generateObject } from 'ai';
import pMap from 'p-map';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../../../errors/app-error';
import { getFileServerSide } from '../../../../universal/upload/get-file.server';
import { resizeImageToGeminiImage } from '../../../../utils/images/resize-image-to-gemini-image';
@@ -12,18 +11,10 @@ import { getEnvelopeById } from '../../../envelope/get-envelope-by-id';
import { createEnvelopeRecipients } from '../../../recipient/create-envelope-recipients';
import { vertex } from '../../google';
import { pdfToImages } from '../../pdf-to-images';
import {
buildRecipientContextMessage,
normalizeDetectedField,
resolveRecipientFromKey,
} from './helpers';
import { buildRecipientContextMessage, normalizeDetectedField, resolveRecipientFromKey } from './helpers';
import { SYSTEM_PROMPT } from './prompt';
import { ZSubmitDetectedFieldsInputSchema } from './schema';
import type {
NormalizedFieldWithContext,
NormalizedFieldWithPage,
RecipientContext,
} from './types';
import type { NormalizedFieldWithContext, NormalizedFieldWithPage, RecipientContext } from './types';
export type DetectFieldsFromEnvelopeOptions = {
context?: string;
@@ -245,12 +236,7 @@ type DetectFieldsFromPageOptions = {
context?: string;
};
const detectFieldsFromPage = async ({
image,
pageNumber,
recipients,
context,
}: DetectFieldsFromPageOptions) => {
const detectFieldsFromPage = async ({ image, pageNumber, recipients, context }: DetectFieldsFromPageOptions) => {
// Resize to 1000x1000 for consistent coordinate mapping
const resizedImage = await resizeImageToGeminiImage({ image });
@@ -29,9 +29,7 @@ const ZBox2DSchema = z.array(z.number().min(0).max(1000)).length(4);
* Schema for a detected field.
*/
export const ZDetectedFieldSchema = z.object({
type: ZDetectableFieldType.describe(
`The field type based on nearby labels and visual appearance`,
),
type: ZDetectableFieldType.describe(`The field type based on nearby labels and visual appearance`),
recipientKey: z
.string()
.describe(
@@ -73,10 +73,7 @@ export type DetectRecipientsFromPdfOptions = {
onProgress?: (progress: DetectRecipientsProgress) => void;
};
export const detectRecipientsFromPdf = async ({
pdfBytes,
onProgress,
}: DetectRecipientsFromPdfOptions) => {
export const detectRecipientsFromPdf = async ({ pdfBytes, onProgress }: DetectRecipientsFromPdfOptions) => {
const pageImages = await pdfToImages(pdfBytes);
if (pageImages.length === 0) {
@@ -105,10 +102,7 @@ const formatDetectedRecipients = (recipients: TDetectedRecipientSchema[]) => {
return `\n\nRecipients detected so far:\n${formatted}`;
};
const isDuplicateRecipient = (
recipient: TDetectedRecipientSchema,
existing: TDetectedRecipientSchema,
) => {
const isDuplicateRecipient = (recipient: TDetectedRecipientSchema, existing: TDetectedRecipientSchema) => {
if (recipient.email && existing.email) {
return recipient.email.toLowerCase() === existing.email.toLowerCase();
}
@@ -120,10 +114,7 @@ const isDuplicateRecipient = (
return false;
};
const mergeRecipients = (
existingRecipients: TDetectedRecipientSchema[],
newRecipients: TDetectedRecipientSchema[],
) => {
const mergeRecipients = (existingRecipients: TDetectedRecipientSchema[], newRecipients: TDetectedRecipientSchema[]) => {
const merged = [...existingRecipients];
for (const recipient of newRecipients) {
@@ -169,10 +160,7 @@ Please analyze these pages and submit any recipients you find using the tool. I
Please analyze these pages and submit any NEW recipients you find (not already listed above) using the tool.`;
};
const detectRecipientsFromImages = async ({
images,
onProgress,
}: DetectRecipientsFromImagesOptions) => {
const detectRecipientsFromImages = async ({ images, onProgress }: DetectRecipientsFromImagesOptions) => {
const imageChunks = chunk(images, MAX_PAGES_PER_CHUNK);
const totalChunks = imageChunks.length;
@@ -16,9 +16,7 @@ export const ZDetectedRecipientSchema = z.object({
export type TDetectedRecipientSchema = z.infer<typeof ZDetectedRecipientSchema>;
export const ZDetectedRecipientsSchema = z.object({
recipients: z
.array(ZDetectedRecipientSchema)
.describe('The list of detected recipients from the document'),
recipients: z.array(ZDetectedRecipientSchema).describe('The list of detected recipients from the document'),
});
export type TDetectedRecipientsSchema = z.infer<typeof ZDetectedRecipientsSchema>;
@@ -1,11 +1,10 @@
import { prisma } from '@documenso/prisma';
import type { Passkey } from '@prisma/client';
import { generateAuthenticationOptions } from '@simplewebauthn/server';
import type { AuthenticatorTransportFuture } from '@simplewebauthn/server';
import { generateAuthenticationOptions } from '@simplewebauthn/server';
import { isoBase64URL } from '@simplewebauthn/server/helpers';
import { DateTime } from 'luxon';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { getAuthenticatorOptions } from '../../utils/authenticator';
@@ -1,10 +1,9 @@
import { generateRegistrationOptions } from '@simplewebauthn/server';
import { prisma } from '@documenso/prisma';
import type { AuthenticatorTransportFuture } from '@simplewebauthn/server';
import { generateRegistrationOptions } from '@simplewebauthn/server';
import { isoBase64URL } from '@simplewebauthn/server/helpers';
import { DateTime } from 'luxon';
import { prisma } from '@documenso/prisma';
import { PASSKEY_TIMEOUT } from '../../constants/auth';
import { getAuthenticatorOptions } from '../../utils/authenticator';
@@ -12,9 +11,7 @@ type CreatePasskeyRegistrationOptions = {
userId: number;
};
export const createPasskeyRegistrationOptions = async ({
userId,
}: CreatePasskeyRegistrationOptions) => {
export const createPasskeyRegistrationOptions = async ({ userId }: CreatePasskeyRegistrationOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
@@ -1,8 +1,7 @@
import { prisma } from '@documenso/prisma';
import { generateAuthenticationOptions } from '@simplewebauthn/server';
import { DateTime } from 'luxon';
import { prisma } from '@documenso/prisma';
import { getAuthenticatorOptions } from '../../utils/authenticator';
type CreatePasskeySigninOptions = {
@@ -1,9 +1,8 @@
import { UserSecurityAuditLogType } from '@prisma/client';
import { verifyRegistrationResponse } from '@simplewebauthn/server';
import type { RegistrationResponseJSON } from '@simplewebauthn/server';
import { isoBase64URL } from '@simplewebauthn/server/helpers';
import { prisma } from '@documenso/prisma';
import { UserSecurityAuditLogType } from '@prisma/client';
import type { RegistrationResponseJSON } from '@simplewebauthn/server';
import { verifyRegistrationResponse } from '@simplewebauthn/server';
import { isoBase64URL } from '@simplewebauthn/server/helpers';
import { MAXIMUM_PASSKEYS } from '../../constants/auth';
import { AppError, AppErrorCode } from '../../errors/app-error';
@@ -1,6 +1,5 @@
import { UserSecurityAuditLogType } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { UserSecurityAuditLogType } from '@prisma/client';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
@@ -10,11 +9,7 @@ export interface DeletePasskeyOptions {
requestMetadata?: RequestMetadata;
}
export const deletePasskey = async ({
userId,
passkeyId,
requestMetadata,
}: DeletePasskeyOptions) => {
export const deletePasskey = async ({ userId, passkeyId, requestMetadata }: DeletePasskeyOptions) => {
await prisma.passkey.findFirstOrThrow({
where: {
id: passkeyId,
@@ -1,8 +1,7 @@
import { prisma } from '@documenso/prisma';
import type { Passkey } from '@prisma/client';
import { Prisma } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import type { FindResultResponse } from '../../types/search-params';
export interface FindPasskeysOptions {
@@ -17,13 +16,7 @@ export interface FindPasskeysOptions {
};
}
export const findPasskeys = async ({
userId,
query = '',
page = 1,
perPage = 10,
orderBy,
}: FindPasskeysOptions) => {
export const findPasskeys = async ({ userId, query = '', page = 1, perPage = 10, orderBy }: FindPasskeysOptions) => {
const orderByColumn = orderBy?.column ?? 'lastUsedAt';
const orderByDirection = orderBy?.direction ?? 'desc';
const orderByNulls: Prisma.NullsOrder | undefined = orderBy?.nulls ?? 'last';
@@ -1,17 +1,12 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import { mailer } from '@documenso/email/mailer';
import { ConfirmEmailTemplate } from '@documenso/email/templates/confirm-email';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/core/macro';
import { createElement } from 'react';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import {
DOCUMENSO_INTERNAL_EMAIL,
USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER,
} from '../../constants/email';
import { DOCUMENSO_INTERNAL_EMAIL, USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER } from '../../constants/email';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
export interface SendConfirmationEmailProps {
@@ -1,10 +1,8 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import { mailer } from '@documenso/email/mailer';
import { ForgotPasswordTemplate } from '@documenso/email/templates/forgot-password';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/core/macro';
import { createElement } from 'react';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
@@ -1,8 +1,7 @@
import { createElement } from 'react';
import { mailer } from '@documenso/email/mailer';
import { ResetPasswordTemplate } from '@documenso/email/templates/reset-password';
import { prisma } from '@documenso/prisma';
import { createElement } from 'react';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { env } from '../../utils/env';
@@ -1,6 +1,5 @@
import { UserSecurityAuditLogType } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { UserSecurityAuditLogType } from '@prisma/client';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
@@ -11,12 +10,7 @@ export interface UpdateAuthenticatorsOptions {
requestMetadata?: RequestMetadata;
}
export const updatePasskey = async ({
userId,
passkeyId,
name,
requestMetadata,
}: UpdateAuthenticatorsOptions) => {
export const updatePasskey = async ({ userId, passkeyId, name, requestMetadata }: UpdateAuthenticatorsOptions) => {
const passkey = await prisma.passkey.findFirstOrThrow({
where: {
id: passkeyId,
@@ -0,0 +1,55 @@
import { IS_BILLING_ENABLED } from '../../constants/app';
import type { TCssVarsSchema } from '../../types/css-vars';
import { ZCssVarsSchema } from '../../types/css-vars';
import { getOrganisationClaimByTeamId } from '../organisation/get-organisation-claims';
import { getTeamSettings } from '../team/get-team-settings';
export type RecipientBrandingPayload = {
allowCustomBranding: boolean;
hidePoweredBy: boolean;
colors: TCssVarsSchema | null;
css: string | null;
};
/**
* Resolve the branding payload for a recipient-facing route, given the team
* the envelope/document belongs to. Reads inherited team-or-org branding settings,
* checks the org's claim flags, and returns a payload safe to send to the client.
*
* Returns a minimal disabled payload if the team is not on a plan that allows
* custom branding.
*/
export const loadRecipientBrandingByTeamId = async ({
teamId,
}: {
teamId: number;
}): Promise<RecipientBrandingPayload> => {
const billingEnabled = IS_BILLING_ENABLED();
const [settings, claim] = await Promise.all([
getTeamSettings({ teamId }),
billingEnabled ? getOrganisationClaimByTeamId({ teamId }).catch(() => null) : Promise.resolve(null),
]);
const allowCustomBranding = !billingEnabled || claim?.flags?.embedSigningWhiteLabel === true;
const hidePoweredBy = !billingEnabled || claim?.flags?.hidePoweredBy === true;
if (!allowCustomBranding) {
return {
allowCustomBranding: false,
hidePoweredBy,
colors: null,
css: null,
};
}
// brandingColors is stored as JSON; parse defensively. Drop unknown keys via Zod.
const parsedColors = settings.brandingColors ? ZCssVarsSchema.safeParse(settings.brandingColors) : null;
return {
allowCustomBranding: true,
hidePoweredBy,
colors: parsedColors?.success ? parsedColors.data : null,
css: settings.brandingCss && settings.brandingCss.length > 0 ? settings.brandingCss : null,
};
};
@@ -0,0 +1,107 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { logger } from '../../utils/logger';
const TURNSTILE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
type TurnstileVerifyResponse = {
success: boolean;
'error-codes': string[];
challenge_ts?: string;
hostname?: string;
};
/**
* Verify a captcha token server-side.
*
* Currently supports Cloudflare Turnstile. This is a no-op if
* `NEXT_PRIVATE_TURNSTILE_SECRET_KEY` is not configured, making captcha
* verification an opt-in feature.
*/
export const verifyCaptchaToken = async ({
token,
ipAddress,
}: {
token?: string | null;
ipAddress?: string | null;
}) => {
const secretKey = process.env.NEXT_PRIVATE_TURNSTILE_SECRET_KEY;
// If no secret key is configured, skip verification.
if (!secretKey) {
return;
}
if (!token) {
logger.warn({
msg: 'Captcha verification rejected: missing token',
ipAddress,
});
throw new AppError(AppErrorCode.INVALID_CAPTCHA, {
message: 'Captcha token is required',
statusCode: 400,
});
}
const formData = new URLSearchParams();
formData.append('secret', secretKey);
formData.append('response', token);
if (ipAddress) {
formData.append('remoteip', ipAddress);
}
let response: Response;
try {
response = await fetch(TURNSTILE_VERIFY_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: formData.toString(),
});
} catch (err) {
logger.error({
msg: 'Captcha verification failed: network error calling siteverify',
err,
ipAddress,
});
throw new AppError(AppErrorCode.INVALID_CAPTCHA, {
message: 'Captcha verification failed',
statusCode: 400,
});
}
if (!response.ok) {
logger.error({
msg: 'Captcha verification failed: non-2xx response from siteverify',
status: response.status,
ipAddress,
});
throw new AppError(AppErrorCode.INVALID_CAPTCHA, {
message: `Captcha verification request failed with status ${response.status}`,
statusCode: 400,
});
}
const result: TurnstileVerifyResponse = await response.json();
if (!result.success) {
logger.warn({
msg: 'Captcha verification rejected by provider',
errorCodes: result['error-codes'],
hostname: result.hostname,
ipAddress,
});
throw new AppError(AppErrorCode.INVALID_CAPTCHA, {
message: `Captcha verification failed: ${result['error-codes']?.join(', ') ?? 'unknown'}`,
statusCode: 400,
});
}
};
+1 -2
View File
@@ -11,8 +11,7 @@ export const getCertificateStatus = () => {
return { isAvailable: true };
}
const defaultPath =
env('NODE_ENV') === 'production' ? '/opt/documenso/cert.p12' : './example/cert.p12';
const defaultPath = env('NODE_ENV') === 'production' ? '/opt/documenso/cert.p12' : './example/cert.p12';
const filePath = env('NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH') || defaultPath;
+1 -2
View File
@@ -1,7 +1,6 @@
import { z } from 'zod';
import { DOCUMENSO_ENCRYPTION_SECONDARY_KEY } from '@documenso/lib/constants/crypto';
import { symmetricEncrypt } from '@documenso/lib/universal/crypto';
import { z } from 'zod';
export const ZEncryptedDataSchema = z.object({
data: z.string(),
@@ -1,18 +1,24 @@
import type { DocumentDataType } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import type { DocumentDataType } from '@prisma/client';
export type CreateDocumentDataOptions = {
type: DocumentDataType;
data: string;
/**
* The initial data that was used to create the document data.
*
* If not provided, the current data will be used.
*/
initialData?: string;
};
export const createDocumentData = async ({ type, data }: CreateDocumentDataOptions) => {
export const createDocumentData = async ({ type, data, initialData }: CreateDocumentDataOptions) => {
return await prisma.documentData.create({
data: {
type,
data,
initialData: data,
initialData: initialData || data,
},
});
};
@@ -1,16 +1,8 @@
import {
type DocumentDistributionMethod,
type DocumentSigningOrder,
EnvelopeType,
} from '@prisma/client';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import {
createDocumentAuditLogData,
diffDocumentMetaChanges,
} from '@documenso/lib/utils/document-audit-logs';
import { createDocumentAuditLogData, diffDocumentMetaChanges } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import { type DocumentDistributionMethod, type DocumentSigningOrder, EnvelopeType } from '@prisma/client';
import type { SupportedLanguageCodes } from '../../constants/i18n';
import { AppError, AppErrorCode } from '../../errors/app-error';
@@ -1,3 +1,10 @@
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 } from '@documenso/lib/types/document-audit-logs';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import {
DocumentSigningOrder,
DocumentStatus,
@@ -8,27 +15,17 @@ import {
SigningStatus,
WebhookTriggerEvents,
} from '@prisma/client';
import {
DOCUMENT_AUDIT_LOG_TYPE,
RECIPIENT_DIFF_TYPE,
} from '@documenso/lib/types/document-audit-logs';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import { DateTime } from 'luxon';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { jobs } from '../../jobs/client';
import type { TRecipientAccessAuth } from '../../types/document-auth';
import { DocumentAuth } from '../../types/document-auth';
import {
ZWebhookDocumentSchema,
mapEnvelopeToWebhookDocumentPayload,
} from '../../types/webhook-payload';
import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../types/webhook-payload';
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 +91,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,55 +105,15 @@ 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(
`Recipient ${recipient.id} attempted to complete the document before it was their turn`,
);
throw new Error(`Recipient ${recipient.id} attempted to complete the document before it was their turn`);
}
}
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',
});
}
const { derivedRecipientAccessAuth, derivedRecipientActionAuth } = extractDocumentAuthMethods({
documentAuth: envelope.authOptions,
recipientAuth: recipient.authOptions,
@@ -229,6 +188,110 @@ 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: {
@@ -299,6 +362,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: {
@@ -372,6 +447,7 @@ export const completeDocumentWithToken = async ({
where: { id: nextRecipient.id },
data: {
sendStatus: SendStatus.SENT,
sentAt: new Date(),
...(nextSigner && envelope.documentMeta?.allowDictateNextSigner
? {
name: nextSigner.name,
@@ -380,16 +456,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,
},
});
}
}
@@ -1,22 +1,17 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import type { DocumentMeta, Envelope, Recipient, User } from '@prisma/client';
import { DocumentStatus, EnvelopeType, SendStatus, WebhookTriggerEvents } from '@prisma/client';
import { mailer } from '@documenso/email/mailer';
import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/core/macro';
import type { DocumentMeta, Envelope, Recipient, User } from '@prisma/client';
import { DocumentStatus, EnvelopeType, SendStatus, WebhookTriggerEvents } from '@prisma/client';
import { createElement } from 'react';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import {
ZWebhookDocumentSchema,
mapEnvelopeToWebhookDocumentPayload,
} from '../../types/webhook-payload';
import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../types/webhook-payload';
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
import { isDocumentCompleted } from '../../utils/document';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
@@ -34,12 +29,7 @@ export type DeleteDocumentOptions = {
requestMetadata: ApiRequestMetadata;
};
export const deleteDocument = async ({
id,
userId,
teamId,
requestMetadata,
}: DeleteDocumentOptions) => {
export const deleteDocument = async ({ id, userId, teamId, requestMetadata }: DeleteDocumentOptions) => {
const user = await prisma.user.findUnique({
where: {
id: userId,
@@ -93,6 +83,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 +109,6 @@ export const deleteDocument = async ({
});
}
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_CANCELLED,
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelope)),
userId,
teamId,
});
return envelope;
};
@@ -131,11 +121,7 @@ type HandleDocumentOwnerDeleteOptions = {
requestMetadata: ApiRequestMetadata;
};
const handleDocumentOwnerDelete = async ({
envelope,
user,
requestMetadata,
}: HandleDocumentOwnerDeleteOptions) => {
const handleDocumentOwnerDelete = async ({ envelope, user, requestMetadata }: HandleDocumentOwnerDeleteOptions) => {
if (envelope.deletedAt) {
return;
}
@@ -199,9 +185,7 @@ const handleDocumentOwnerDelete = async ({
});
});
const isEnvelopeDeleteEmailEnabled = extractDerivedDocumentEmailSettings(
envelope.documentMeta,
).documentDeleted;
const isEnvelopeDeleteEmailEnabled = extractDerivedDocumentEmailSettings(envelope.documentMeta).documentDeleted;
if (!isEnvelopeDeleteEmailEnabled) {
return deletedEnvelope;
@@ -1,6 +1,5 @@
import { type DocumentAuditLog, EnvelopeType, type Prisma } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { type DocumentAuditLog, EnvelopeType, type Prisma } from '@prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
@@ -99,7 +98,7 @@ export const findDocumentAuditLogs = async ({
}),
]);
let nextCursor: string | undefined = undefined;
let nextCursor: string | undefined;
const parsedData = data.map((auditLog) => parseDocumentAuditLogData(auditLog));
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,5 @@
import { DocumentStatus, EnvelopeType } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, EnvelopeType } from '@prisma/client';
import { mapSecondaryIdToDocumentId } from '../../utils/envelope';
@@ -13,7 +12,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,
@@ -56,6 +55,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) {
@@ -1,6 +1,5 @@
import { EnvelopeType } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { EnvelopeType } from '@prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TDocumentAuthMethods } from '../../types/document-auth';
@@ -96,6 +95,7 @@ export const getDocumentAndSenderByToken = async ({
title: true,
order: true,
envelopeId: true,
documentDataId: true,
documentData: true,
},
},
@@ -7,9 +7,7 @@ export type GetDocumentCertificateAuditLogsOptions = {
envelopeId: string;
};
export const getDocumentCertificateAuditLogs = async ({
envelopeId,
}: GetDocumentCertificateAuditLogsOptions) => {
export const getDocumentCertificateAuditLogs = async ({ envelopeId }: GetDocumentCertificateAuditLogsOptions) => {
const rawAuditLogs = await prisma.documentAuditLog.findMany({
where: {
envelopeId,
@@ -9,11 +9,7 @@ export type GetDocumentWithDetailsByIdOptions = {
teamId: number;
};
export const getDocumentWithDetailsById = async ({
id,
userId,
teamId,
}: GetDocumentWithDetailsByIdOptions) => {
export const getDocumentWithDetailsById = async ({ id, userId, teamId }: GetDocumentWithDetailsByIdOptions) => {
const envelope = await getEnvelopeById({
id,
type: EnvelopeType.DOCUMENT,
@@ -4,9 +4,7 @@ export type GetRecipientOrSenderByShareLinkSlugOptions = {
slug: string;
};
export const getRecipientOrSenderByShareLinkSlug = async ({
slug,
}: GetRecipientOrSenderByShareLinkSlugOptions) => {
export const getRecipientOrSenderByShareLinkSlug = async ({ slug }: GetRecipientOrSenderByShareLinkSlugOptions) => {
const { envelopeId, email } = await prisma.documentShareLink.findFirstOrThrow({
where: {
slug,
+258 -343
View File
@@ -1,368 +1,283 @@
import type { Prisma, User } from '@prisma/client';
import { DocumentVisibility, EnvelopeType, SigningStatus, TeamMemberRole } from '@prisma/client';
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 { DocumentStatus, EnvelopeType, RecipientRole, SigningStatus, TeamMemberRole } from '@prisma/client';
import type { Expression, ExpressionBuilder, SelectQueryBuilder, SqlBool } from 'kysely';
import { DateTime } from 'luxon';
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[];
};
export const getStats = async ({
user,
period,
search = '',
folderId,
...options
}: GetStatsInput) => {
let createdAt: Prisma.EnvelopeWhereInput['createdAt'];
/**
* 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();
if (period) {
const daysAgo = parseInt(period.replace(/d$/, ''), 10);
return Math.min(Number(result.total ?? 0), STATS_COUNT_CAP);
};
const startOfPeriod = DateTime.now().minus({ days: daysAgo }).startOf('day');
export const getStats = async ({ userId, teamId, period, search = '', folderId, senderIds }: GetStatsInput) => {
const user = await prisma.user.findFirstOrThrow({
where: { id: userId },
select: { id: true, email: true },
});
createdAt = {
gte: startOfPeriod.toJSDate(),
};
}
const team = await getTeamById({ userId, teamId });
const [ownerCounts, notSignedCounts, hasSignedCounts] = await (options.team
? getTeamCounts({
...options.team,
createdAt,
currentUserEmail: user.email,
userId: user.id,
search,
folderId,
})
: getCounts({ user, createdAt, search, folderId }));
const teamEmail = team.teamEmail?.email ?? null;
const currentTeamRole = team.currentTeamRole ?? TeamMemberRole.MEMBER;
const allowedVisibilities = TEAM_DOCUMENT_VISIBILITY_MAP[currentTeamRole];
const stats: Record<ExtendedDocumentStatus, number> = {
[ExtendedDocumentStatus.DRAFT]: 0,
[ExtendedDocumentStatus.PENDING]: 0,
[ExtendedDocumentStatus.COMPLETED]: 0,
[ExtendedDocumentStatus.REJECTED]: 0,
[ExtendedDocumentStatus.INBOX]: 0,
[ExtendedDocumentStatus.ALL]: 0,
const searchQuery = search.trim();
const hasSearch = searchQuery.length > 0;
const searchPattern = `%${searchQuery}%`;
// ─── Base query builder ──────────────────────────────────────────────
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,19 +1,17 @@
import { prisma } from '@documenso/prisma';
import type { Envelope, Recipient } from '@prisma/client';
import { verifyAuthenticationResponse } from '@simplewebauthn/server';
import { isoBase64URL } from '@simplewebauthn/server/helpers';
import { match } from 'ts-pattern';
import { prisma } from '@documenso/prisma';
import { validateTwoFactorTokenFromEmail } from '../2fa/email/validate-2fa-token-from-email';
import { verifyTwoFactorAuthenticationToken } from '../2fa/verify-2fa-token';
import { verifyPassword } from '../2fa/verify-password';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TDocumentAuth, TDocumentAuthMethods } from '../../types/document-auth';
import { DocumentAuth } from '../../types/document-auth';
import type { TAuthenticationResponseJSONSchema } from '../../types/webauthn';
import { getAuthenticatorOptions } from '../../utils/authenticator';
import { extractDocumentAuthMethods } from '../../utils/document-auth';
import { validateTwoFactorTokenFromEmail } from '../2fa/email/validate-2fa-token-from-email';
import { verifyTwoFactorAuthenticationToken } from '../2fa/verify-2fa-token';
import { verifyPassword } from '../2fa/verify-password';
type IsRecipientAuthorizedOptions = {
// !: Probably find a better name than 'ACCESS_2FA' if requirements change.
@@ -73,10 +71,7 @@ export const isRecipientAuthorized = async ({
.exhaustive();
// Early true return when auth is not required.
if (
authMethods.length === 0 ||
authMethods.some((method) => method === DocumentAuth.EXPLICIT_NONE)
) {
if (authMethods.length === 0 || authMethods.some((method) => method === DocumentAuth.EXPLICIT_NONE)) {
return true;
}
@@ -1,7 +1,6 @@
import { EnvelopeType, SigningStatus } from '@prisma/client';
import { jobs } from '@documenso/lib/jobs/client';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, EnvelopeType, SigningStatus } from '@prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
@@ -9,6 +8,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;
@@ -17,12 +17,7 @@ export type RejectDocumentWithTokenOptions = {
requestMetadata?: RequestMetadata;
};
export async function rejectDocumentWithToken({
token,
id,
reason,
requestMetadata,
}: RejectDocumentWithTokenOptions) {
export async function rejectDocumentWithToken({ token, id, reason, requestMetadata }: RejectDocumentWithTokenOptions) {
// Find the recipient and document in a single query
const recipient = await prisma.recipient.findFirst({
where: {
@@ -42,6 +37,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({
@@ -1,5 +1,12 @@
import { createElement } from 'react';
import { mailer } from '@documenso/email/mailer';
import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite';
import { resolveExpiresAt } from '@documenso/lib/constants/envelope-expiration';
import { RECIPIENT_ROLE_TO_EMAIL_TYPE, RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
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';
import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/core/macro';
import {
DocumentStatus,
@@ -7,29 +14,21 @@ import {
OrganisationType,
RecipientRole,
SigningStatus,
WebhookTriggerEvents,
} from '@prisma/client';
import { mailer } from '@documenso/email/mailer';
import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite';
import {
RECIPIENT_ROLES_DESCRIPTION,
RECIPIENT_ROLE_TO_EMAIL_TYPE,
} from '@documenso/lib/constants/recipient-roles';
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';
import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
import { prisma } from '@documenso/prisma';
import { createElement } from 'react';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } 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;
@@ -39,13 +38,7 @@ export type ResendDocumentOptions = {
requestMetadata: ApiRequestMetadata;
};
export const resendDocument = async ({
id,
userId,
recipients,
teamId,
requestMetadata,
}: ResendDocumentOptions) => {
export const resendDocument = async ({ id, userId, recipients, teamId, requestMetadata }: ResendDocumentOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
@@ -94,11 +87,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;
@@ -107,15 +120,14 @@ export const resendDocument = async ({
return envelope;
}
const { branding, emailLanguage, organisationType, senderEmail, replyToEmail } =
await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: envelope.teamId,
},
meta: envelope.documentMeta,
});
const { branding, emailLanguage, organisationType, senderEmail, replyToEmail } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: envelope.teamId,
},
meta: envelope.documentMeta,
});
await Promise.all(
recipientsToRemind.map(async (recipient) => {
@@ -130,9 +142,7 @@ export const resendDocument = async ({
const { email, name } = recipient;
const selfSigner = email === user.email;
const recipientActionVerb = i18n
._(RECIPIENT_ROLES_DESCRIPTION[recipient.role].actionVerb)
.toLowerCase();
const recipientActionVerb = i18n._(RECIPIENT_ROLES_DESCRIPTION[recipient.role].actionVerb).toLowerCase();
let emailMessage = envelope.documentMeta.message || '';
let emailSubject = i18n._(msg`Reminder: Please ${recipientActionVerb} this document`);
@@ -145,9 +155,7 @@ export const resendDocument = async ({
}
if (organisationType === OrganisationType.ORGANISATION) {
emailSubject = i18n._(
msg`Reminder: ${envelope.team.name} invited you to ${recipientActionVerb} a document`,
);
emailSubject = i18n._(msg`Reminder: ${envelope.team.name} invited you to ${recipientActionVerb} a document`);
emailMessage =
envelope.documentMeta.message ||
i18n._(
@@ -192,45 +200,46 @@ 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,11 @@
import { DocumentStatus, EnvelopeType } from '@prisma/client';
import type { Envelope, Recipient, User } from '@prisma/client';
import { DocumentVisibility, TeamMemberRole } from '@prisma/client';
import { formatDocumentsPath, getHighestTeamRoleInGroup } from '@documenso/lib/utils/teams';
import { prisma } from '@documenso/prisma';
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 { prisma } from '@documenso/prisma';
import { mapSecondaryIdToDocumentId } from '../../utils/envelope';
import { getUserTeamGroups } from '../team/get-user-team-groups';
export type SearchDocumentsWithKeywordOptions = {
query: string;
@@ -18,110 +13,84 @@ export type SearchDocumentsWithKeywordOptions = {
limit?: number;
};
export const searchDocumentsWithKeyword = async ({
query,
userId,
limit = 20,
}: SearchDocumentsWithKeywordOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
export const searchDocumentsWithKeyword = async ({ query, userId, limit = 20 }: SearchDocumentsWithKeywordOptions) => {
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 +98,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 +123,26 @@ 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,11 +1,9 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import { DocumentSource, EnvelopeType } from '@prisma/client';
import { mailer } from '@documenso/email/mailer';
import { DocumentCompletedEmailTemplate } from '@documenso/email/templates/document-completed';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/core/macro';
import { DocumentSource, EnvelopeType } from '@prisma/client';
import { createElement } from 'react';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
@@ -86,8 +84,7 @@ export const sendCompletedEmail = async ({ id, requestMetadata }: SendDocumentOp
const file = await getFileServerSide(envelopeItem.documentData);
// Use the envelope title for version 1, and the envelope item title for version 2.
const fileNameToUse =
envelope.internalVersion === 1 ? envelope.title : envelopeItem.title + '.pdf';
const fileNameToUse = envelope.internalVersion === 1 ? envelope.title : envelopeItem.title + '.pdf';
return {
filename: fileNameToUse.endsWith('.pdf') ? fileNameToUse : fileNameToUse + '.pdf',
@@ -104,9 +101,7 @@ export const sendCompletedEmail = async ({ id, requestMetadata }: SendDocumentOp
)}/${envelope.id}`;
if (envelope.team?.url) {
documentOwnerDownloadLink = `${NEXT_PUBLIC_WEBAPP_URL()}/t/${envelope.team.url}/documents/${
envelope.id
}`;
documentOwnerDownloadLink = `${NEXT_PUBLIC_WEBAPP_URL()}/t/${envelope.team.url}/documents/${envelope.id}`;
}
const emailSettings = extractDerivedDocumentEmailSettings(envelope.documentMeta);
@@ -120,8 +115,7 @@ export const sendCompletedEmail = async ({ id, requestMetadata }: SendDocumentOp
// - Recipient emails are disabled
if (
isOwnerDocumentCompletedEmailEnabled &&
(!envelope.recipients.find((recipient) => recipient.email === owner.email) ||
!isDocumentCompletedEmailEnabled)
(!envelope.recipients.find((recipient) => recipient.email === owner.email) || !isDocumentCompletedEmailEnabled)
) {
const template = createElement(DocumentCompletedEmailTemplate, {
documentName: envelope.title,
@@ -177,9 +171,7 @@ export const sendCompletedEmail = async ({ id, requestMetadata }: SendDocumentOp
return;
}
const recipientsToNotify = envelope.recipients.filter((recipient) =>
isRecipientEmailValidForSending(recipient),
);
const recipientsToNotify = envelope.recipients.filter((recipient) => isRecipientEmailValidForSending(recipient));
await Promise.all(
recipientsToNotify.map(async (recipient) => {
@@ -1,10 +1,8 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import { mailer } from '@documenso/email/mailer';
import { DocumentSuperDeleteEmailTemplate } from '@documenso/email/templates/document-super-delete';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/core/macro';
import { createElement } from 'react';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
@@ -42,9 +40,7 @@ export const sendDeleteEmail = async ({ envelopeId, reason }: SendDeleteEmailOpt
});
}
const isDocumentDeletedEmailEnabled = extractDerivedDocumentEmailSettings(
envelope.documentMeta,
).documentDeleted;
const isDocumentDeletedEmailEnabled = extractDerivedDocumentEmailSettings(envelope.documentMeta).documentDeleted;
if (!isDocumentDeletedEmailEnabled) {
return;
@@ -1,4 +1,10 @@
import type { DocumentData, Envelope, EnvelopeItem, Field } 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';
import { prisma } from '@documenso/prisma';
import { checkboxValidationSigns } from '@documenso/ui/primitives/document-flow/field-items-advanced-settings/constants';
import type { DocumentData, Envelope, EnvelopeItem, Field, Recipient } from '@prisma/client';
import {
DocumentSigningOrder,
DocumentStatus,
@@ -10,13 +16,8 @@ import {
WebhookTriggerEvents,
} from '@prisma/client';
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';
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';
@@ -28,20 +29,14 @@ import {
ZRadioFieldMeta,
ZTextFieldMeta,
} 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 { putNormalizedPdfFileServerSide } from '../../universal/upload/put-file.server';
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 {
getRecipientsWithMissingFields,
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';
@@ -54,13 +49,7 @@ export type SendDocumentOptions = {
requestMetadata: ApiRequestMetadata;
};
export const sendDocument = async ({
id,
userId,
teamId,
sendEmail,
requestMetadata,
}: SendDocumentOptions) => {
export const sendDocument = async ({ id, userId, teamId, sendEmail, requestMetadata }: SendDocumentOptions) => {
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id,
type: EnvelopeType.DOCUMENT,
@@ -152,22 +141,20 @@ export const sendDocument = async ({
});
// Validate that recipients who require fields (e.g., signers need signature fields) have them.
const recipientsWithMissingFields = getRecipientsWithMissingFields(
envelope.recipients,
envelope.fields,
);
const recipientsWithMissingFields = getRecipientsWithMissingFields(envelope.recipients, envelope.fields);
if (recipientsWithMissingFields.length > 0) {
const missingRecipientIds = recipientsWithMissingFields.map((r) => r.id).join(', ');
const missingRecipientDescriptions = recipientsWithMissingFields
.map((r) => (r.name ? `${r.name} (${r.email}, id: ${r.id})` : `${r.email} (id: ${r.id})`))
.join(', ');
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `The following recipients are missing required fields: ${missingRecipientIds}. Signers must have at least one signature field.`,
message: `The following recipients are missing required fields: ${missingRecipientDescriptions}. Signers must have at least one signature field.`,
});
}
const allRecipientsHaveNoActionToTake = envelope.recipients.every(
(recipient) =>
recipient.role === RecipientRole.CC || recipient.signingStatus === SigningStatus.SIGNED,
(recipient) => recipient.role === RecipientRole.CC || recipient.signingStatus === SigningStatus.SIGNED,
);
if (allRecipientsHaveNoActionToTake) {
@@ -204,7 +191,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) {
@@ -257,6 +244,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,
@@ -349,6 +358,7 @@ const injectFormValuesIntoDocument = async (
*/
export const extractFieldAutoInsertValues = (
unknownField: Field,
recipient: Pick<Recipient, 'email'>,
): { fieldId: number; customText: string } | null => {
const parsedField = ZFieldAndMetaSchema.safeParse(unknownField);
@@ -361,6 +371,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);
@@ -413,11 +435,7 @@ export const extractFieldAutoInsertValues = (
// Auto insert checkbox fields with the pre-checked values.
if (field.type === FieldType.CHECKBOX) {
const {
values = [],
validationRule,
validationLength,
} = ZCheckboxFieldMeta.parse(field.fieldMeta);
const { values = [], validationRule, validationLength } = ZCheckboxFieldMeta.parse(field.fieldMeta);
const checkedIndices: number[] = [];
@@ -1,11 +1,9 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import { EnvelopeType } from '@prisma/client';
import { mailer } from '@documenso/email/mailer';
import { DocumentPendingEmailTemplate } from '@documenso/email/templates/document-pending';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/core/macro';
import { EnvelopeType } from '@prisma/client';
import { createElement } from 'react';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
@@ -58,9 +56,7 @@ export const sendPendingEmail = async ({ id, recipientId }: SendPendingEmailOpti
meta: envelope.documentMeta,
});
const isDocumentPendingEmailEnabled = extractDerivedDocumentEmailSettings(
envelope.documentMeta,
).documentPending;
const isDocumentPendingEmailEnabled = extractDerivedDocumentEmailSettings(envelope.documentMeta).documentPending;
if (!isDocumentPendingEmailEnabled) {
return;
@@ -1,16 +1,11 @@
import { EnvelopeType, ReadStatus, SendStatus } from '@prisma/client';
import { 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';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import { EnvelopeType, ReadStatus, SendStatus, WebhookTriggerEvents } from '@prisma/client';
import type { TDocumentAccessAuthTypes } from '../../types/document-auth';
import {
ZWebhookDocumentSchema,
mapEnvelopeToWebhookDocumentPayload,
} from '../../types/webhook-payload';
import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../types/webhook-payload';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export type ViewedDocumentOptions = {
@@ -19,11 +14,7 @@ export type ViewedDocumentOptions = {
requestMetadata?: RequestMetadata;
};
export const viewedDocument = async ({
token,
recipientAccessAuth,
requestMetadata,
}: ViewedDocumentOptions) => {
export const viewedDocument = async ({ token, recipientAccessAuth, requestMetadata }: ViewedDocumentOptions) => {
const recipient = await prisma.recipient.findFirst({
where: {
token,
@@ -70,6 +61,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 +86,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,
@@ -1,5 +1,3 @@
import { P, match } from 'ts-pattern';
import type { BrandingSettings } from '@documenso/email/providers/branding';
import { prisma } from '@documenso/prisma';
import type {
@@ -9,11 +7,8 @@ import type {
OrganisationEmail,
OrganisationType,
} from '@documenso/prisma/client';
import {
EmailDomainStatus,
type OrganisationClaim,
type OrganisationGlobalSettings,
} from '@documenso/prisma/client';
import { EmailDomainStatus, type OrganisationClaim, type OrganisationGlobalSettings } from '@documenso/prisma/client';
import { match, P } from 'ts-pattern';
import { DOCUMENSO_INTERNAL_EMAIL } from '../../constants/email';
import { AppError, AppErrorCode } from '../../errors/app-error';
@@ -80,9 +75,7 @@ type EmailContextResponse = {
emailLanguage: string;
};
export const getEmailContext = async (
options: GetEmailContextOptions,
): Promise<EmailContextResponse> => {
export const getEmailContext = async (options: GetEmailContextOptions): Promise<EmailContextResponse> => {
const { source, meta } = options;
let emailContext: Omit<EmailContextResponse, 'senderEmail' | 'replyToEmail' | 'emailLanguage'>;
@@ -208,18 +201,11 @@ const handleTeamEmailContext = async (teamId: number) => {
const allowedEmails = getAllowedEmails(organisation);
const teamSettings = extractDerivedTeamSettings(
organisation.organisationGlobalSettings,
team.teamGlobalSettings,
);
const teamSettings = extractDerivedTeamSettings(organisation.organisationGlobalSettings, team.teamGlobalSettings);
return {
allowedEmails,
branding: teamGlobalSettingsToBranding(
teamSettings,
teamId,
claims.flags.hidePoweredBy ?? false,
),
branding: teamGlobalSettingsToBranding(teamSettings, teamId, claims.flags.hidePoweredBy ?? false),
settings: teamSettings,
claims,
organisationType: organisation.type,
@@ -32,8 +32,7 @@ export const createEmbeddingPresignToken = async ({
const minExpirationMinutes = isDevelopment ? 0 : 5;
// Ensure expiresIn is at least the minimum allowed value
const effectiveExpiresIn =
expiresIn !== undefined && expiresIn >= minExpirationMinutes ? expiresIn : 60; // Default to 1 hour if not specified or below minimum
const effectiveExpiresIn = expiresIn !== undefined && expiresIn >= minExpirationMinutes ? expiresIn : 60; // Default to 1 hour if not specified or below minimum
const expiresAt = now.plus({ minutes: effectiveExpiresIn });
@@ -1,8 +1,7 @@
import { prisma } from '@documenso/prisma';
import type { JWTPayload } from 'jose';
import { decodeJwt, jwtVerify } from 'jose';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
export type VerifyEmbeddingPresignTokenOptions = {
@@ -10,10 +9,7 @@ export type VerifyEmbeddingPresignTokenOptions = {
scope?: string;
};
export const verifyEmbeddingPresignToken = async ({
token,
scope,
}: VerifyEmbeddingPresignTokenOptions) => {
export const verifyEmbeddingPresignToken = async ({ token, scope }: VerifyEmbeddingPresignTokenOptions) => {
// First decode the JWT to get the claims without verification
let decodedToken: JWTPayload;
@@ -60,6 +56,15 @@ export const verifyEmbeddingPresignToken = async ({
where: {
id: tokenId,
},
include: {
user: {
select: {
id: true,
name: true,
email: true,
},
},
},
});
if (!apiToken) {
@@ -69,7 +74,7 @@ export const verifyEmbeddingPresignToken = async ({
}
// This should never happen but we need to narrow types
if (!apiToken.userId) {
if (!apiToken.userId || !apiToken.user) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Invalid presign token: API token does not have a user attached',
});
@@ -119,5 +124,10 @@ export const verifyEmbeddingPresignToken = async ({
return {
...apiToken,
userId,
user: {
id: apiToken.user.id,
name: apiToken.user.name,
email: apiToken.user.email,
},
};
};
@@ -1,7 +1,6 @@
import { DocumentStatus } from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { DocumentStatus } from '@prisma/client';
import { buildTeamWhereQuery } from '../../utils/teams';
@@ -15,12 +14,7 @@ export type CreateAttachmentOptions = {
};
};
export const createAttachment = async ({
envelopeId,
teamId,
userId,
data,
}: CreateAttachmentOptions) => {
export const createAttachment = async ({ envelopeId, teamId, userId, data }: CreateAttachmentOptions) => {
const envelope = await prisma.envelope.findFirst({
where: {
id: envelopeId,
@@ -1,7 +1,6 @@
import { DocumentStatus } from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { DocumentStatus } from '@prisma/client';
import { buildTeamWhereQuery } from '../../utils/teams';
@@ -6,10 +6,7 @@ export type FindAttachmentsByTokenOptions = {
token: string;
};
export const findAttachmentsByToken = async ({
envelopeId,
token,
}: FindAttachmentsByTokenOptions) => {
export const findAttachmentsByToken = async ({ envelopeId, token }: FindAttachmentsByTokenOptions) => {
const envelope = await prisma.envelope.findFirst({
where: {
id: envelopeId,
@@ -42,10 +39,7 @@ export type FindAttachmentsByTeamOptions = {
teamId: number;
};
export const findAttachmentsByTeam = async ({
envelopeId,
teamId,
}: FindAttachmentsByTeamOptions) => {
export const findAttachmentsByTeam = async ({ envelopeId, teamId }: FindAttachmentsByTeamOptions) => {
const envelope = await prisma.envelope.findFirst({
where: {
id: envelopeId,
@@ -1,7 +1,6 @@
import { DocumentStatus } from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { DocumentStatus } from '@prisma/client';
import { buildTeamWhereQuery } from '../../utils/teams';
@@ -0,0 +1,172 @@
import {
convertPlaceholdersToFieldInputs,
extractPdfPlaceholders,
} from '@documenso/lib/server-only/pdf/auto-place-fields';
import { findRecipientByPlaceholder } from '@documenso/lib/server-only/pdf/helpers';
import { insertFormValuesInPdf } from '@documenso/lib/server-only/pdf/insert-form-values-in-pdf';
import { normalizePdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { prefixedId } from '@documenso/lib/universal/id';
import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import type { Envelope, EnvelopeItem, Recipient } from '@prisma/client';
type UnsafeCreateEnvelopeItemsOptions = {
files: {
clientId?: string;
file: File;
orderOverride?: number;
}[];
envelope: Envelope & {
envelopeItems: EnvelopeItem[];
recipients: Recipient[];
};
user: {
id: number;
name: string | null;
email: string;
};
apiRequestMetadata: ApiRequestMetadata;
};
/**
* Create envelope items.
*
* It is assumed all prior validation has been completed.
*/
export const UNSAFE_createEnvelopeItems = async ({
files,
envelope,
user,
apiRequestMetadata,
}: UnsafeCreateEnvelopeItemsOptions) => {
const currentHighestOrderValue = envelope.envelopeItems[envelope.envelopeItems.length - 1]?.order ?? 1;
// For each file: normalize, extract & clean placeholders, then upload.
const envelopeItemsToCreate = await Promise.all(
files.map(async ({ file, orderOverride, clientId }, index) => {
let buffer = Buffer.from(await file.arrayBuffer());
if (envelope.formValues) {
buffer = await insertFormValuesInPdf({ pdf: buffer, formValues: envelope.formValues });
}
const normalized = await normalizePdf(buffer, {
flattenForm: envelope.type !== 'TEMPLATE',
});
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized);
const { documentData } = await putPdfFileServerSide({
name: file.name,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(cleanedPdf),
});
return {
id: prefixedId('envelope_item'),
title: file.name,
clientId,
documentDataId: documentData.id,
placeholders,
order: orderOverride ?? currentHighestOrderValue + index + 1,
};
}),
);
return await prisma.$transaction(async (tx) => {
const createdItems = await tx.envelopeItem.createManyAndReturn({
data: envelopeItemsToCreate.map((item) => ({
id: item.id,
envelopeId: envelope.id,
title: item.title,
documentDataId: item.documentDataId,
order: item.order,
})),
include: {
documentData: true,
},
});
await tx.documentAuditLog.createMany({
data: createdItems.map((item) =>
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.ENVELOPE_ITEM_CREATED,
envelopeId: envelope.id,
data: {
envelopeItemId: item.id,
envelopeItemTitle: item.title,
},
user: {
name: user.name,
email: user.email,
},
requestMetadata: apiRequestMetadata.requestMetadata,
}),
),
});
// Create fields from placeholders if the envelope already has recipients.
if (envelope.recipients.length > 0) {
const orderedRecipients = [...envelope.recipients].sort((a, b) => {
const aOrder = a.signingOrder ?? Number.MAX_SAFE_INTEGER;
const bOrder = b.signingOrder ?? Number.MAX_SAFE_INTEGER;
if (aOrder !== bOrder) {
return aOrder - bOrder;
}
return a.id - b.id;
});
for (const uploadedItem of envelopeItemsToCreate) {
if (!uploadedItem.placeholders || uploadedItem.placeholders.length === 0) {
continue;
}
const createdItem = createdItems.find((ci) => ci.documentDataId === uploadedItem.documentDataId);
if (!createdItem) {
continue;
}
const fieldsToCreate = convertPlaceholdersToFieldInputs(
uploadedItem.placeholders,
(recipientPlaceholder, placeholder) =>
findRecipientByPlaceholder(recipientPlaceholder, placeholder, orderedRecipients, orderedRecipients),
createdItem.id,
);
if (fieldsToCreate.length > 0) {
await tx.field.createMany({
data: fieldsToCreate.map((field) => ({
envelopeId: envelope.id,
envelopeItemId: createdItem.id,
recipientId: field.recipientId,
type: field.type,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
customText: '',
inserted: false,
fieldMeta: field.fieldMeta || undefined,
})),
});
}
}
}
return createdItems.map((item) => {
const clientId = envelopeItemsToCreate.find((file) => file.id === item.id)?.clientId;
return {
...item,
clientId,
};
});
});
};
@@ -0,0 +1,67 @@
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';
import { prisma } from '@documenso/prisma';
type UnsafeDeleteEnvelopeItemOptions = {
envelopeId: string;
envelopeItemId: string;
user: {
id: number;
name: string | null;
email: string;
};
apiRequestMetadata: ApiRequestMetadata;
};
export const UNSAFE_deleteEnvelopeItem = async ({
envelopeId,
envelopeItemId,
user,
apiRequestMetadata,
}: UnsafeDeleteEnvelopeItemOptions) => {
const result = await prisma.$transaction(async (tx) => {
const deletedEnvelopeItem = await tx.envelopeItem.delete({
where: {
id: envelopeItemId,
envelopeId,
},
select: {
id: true,
title: true,
documentData: {
select: {
id: true,
},
},
},
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.ENVELOPE_ITEM_DELETED,
envelopeId,
data: {
envelopeItemId: deletedEnvelopeItem.id,
envelopeItemTitle: deletedEnvelopeItem.title,
},
user: {
name: user.name,
email: user.email,
},
requestMetadata: apiRequestMetadata.requestMetadata,
}),
});
return deletedEnvelopeItem;
});
await prisma.documentData.delete({
where: {
id: result.documentData.id,
envelopeItem: {
is: null,
},
},
});
};
@@ -0,0 +1,227 @@
import { normalizePdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import type { Envelope, Field, Recipient } from '@prisma/client';
import { convertPlaceholdersToFieldInputs, extractPdfPlaceholders } from '../pdf/auto-place-fields';
import { findRecipientByPlaceholder } from '../pdf/helpers';
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
type UnsafeReplaceEnvelopeItemPdfOptions = {
envelope: Pick<Envelope, 'id' | 'type' | 'formValues'>;
/**
* Recipients used to resolve placeholder field assignments.
* When provided and placeholders are found in the replacement PDF,
* fields will be auto-created for matching recipients.
*/
recipients: Recipient[];
/**
* The ID of the envelope item which we will be replacing the PDF for.
*/
envelopeItemId: string;
/**
* The ID of the old document data we will be deleting.
*/
oldDocumentDataId: string;
/**
* The data we will be replacing.
*/
data: {
title?: string;
order?: number;
file: File;
};
user: {
id: number;
name: string | null;
email: string;
};
apiRequestMetadata: ApiRequestMetadata;
};
type UnsafeReplaceEnvelopeItemPdfResult = {
updatedItem: {
id: string;
title: string;
envelopeId: string;
order: number;
documentDataId: string;
};
/**
* The full list of fields for the envelope after the replacement.
*
* Only returned when fields were created or deleted during the replacement,
* otherwise `undefined`.
*/
fields: Field[] | undefined;
};
export const UNSAFE_replaceEnvelopeItemPdf = async ({
envelope,
recipients,
envelopeItemId,
oldDocumentDataId,
data,
user,
apiRequestMetadata,
}: UnsafeReplaceEnvelopeItemPdfOptions): Promise<UnsafeReplaceEnvelopeItemPdfResult> => {
let buffer = Buffer.from(await data.file.arrayBuffer());
if (envelope.formValues) {
buffer = await insertFormValuesInPdf({ pdf: buffer, formValues: envelope.formValues });
}
const normalized = await normalizePdf(buffer, {
flattenForm: envelope.type !== 'TEMPLATE',
});
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized);
// Upload the new PDF and get a new DocumentData record.
const { documentData: newDocumentData, filePageCount } = await putPdfFileServerSide({
name: data.file.name,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(cleanedPdf),
});
let didFieldsChange = false;
const updatedEnvelopeItem = await prisma.$transaction(async (tx) => {
const updatedItem = await tx.envelopeItem.update({
where: {
id: envelopeItemId,
envelopeId: envelope.id,
},
data: {
documentDataId: newDocumentData.id,
title: data.title,
order: data.order,
},
});
// Todo: Audit log if we're updating the title or order.
// Delete fields that reference pages beyond the new PDF's page count.
const outOfBoundsFields = await tx.field.findMany({
where: {
envelopeId: envelope.id,
envelopeItemId,
page: {
gt: filePageCount,
},
},
select: {
id: true,
},
});
const deletedFieldIds = outOfBoundsFields.map((f) => f.id);
if (deletedFieldIds.length > 0) {
await tx.field.deleteMany({
where: {
id: {
in: deletedFieldIds,
},
},
});
didFieldsChange = true;
}
if (recipients.length > 0 && placeholders.length > 0) {
const orderedRecipients = [...recipients].sort((a, b) => {
const aOrder = a.signingOrder ?? Number.MAX_SAFE_INTEGER;
const bOrder = b.signingOrder ?? Number.MAX_SAFE_INTEGER;
if (aOrder !== bOrder) {
return aOrder - bOrder;
}
return a.id - b.id;
});
const fieldsToCreate = convertPlaceholdersToFieldInputs(
placeholders,
(recipientPlaceholder, placeholder) =>
findRecipientByPlaceholder(recipientPlaceholder, placeholder, orderedRecipients, orderedRecipients),
updatedItem.id,
);
if (fieldsToCreate.length > 0) {
await tx.field.createMany({
data: fieldsToCreate.map((field) => ({
envelopeId: envelope.id,
envelopeItemId: updatedItem.id,
recipientId: field.recipientId,
type: field.type,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
customText: '',
inserted: false,
fieldMeta: field.fieldMeta || undefined,
})),
});
didFieldsChange = true;
}
}
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.ENVELOPE_ITEM_PDF_REPLACED,
envelopeId: envelope.id,
data: {
envelopeItemId: updatedItem.id,
envelopeItemTitle: updatedItem.title,
},
user: {
name: user.name,
email: user.email,
},
requestMetadata: apiRequestMetadata.requestMetadata,
}),
});
return updatedItem;
});
// Delete the old DocumentData (now orphaned).
await prisma.documentData.delete({
where: {
id: oldDocumentDataId,
},
});
let fields: Field[] | undefined;
if (didFieldsChange) {
try {
fields = await prisma.field.findMany({
where: {
envelopeId: envelope.id,
},
});
} catch (err) {
// Do nothing.
console.error(err);
}
}
return {
updatedItem: updatedEnvelopeItem,
fields,
};
};
@@ -0,0 +1,100 @@
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';
import { prisma } from '@documenso/prisma';
import type { EnvelopeItem, EnvelopeType } from '@prisma/client';
type UnsafeUpdateEnvelopeItemsOptions = {
envelopeId: string;
envelopeType: EnvelopeType;
existingEnvelopeItems: Pick<EnvelopeItem, 'id' | 'title' | 'order'>[];
data: {
envelopeItemId: string;
order?: number;
title?: string;
}[];
user: {
name: string | null;
email: string;
};
apiRequestMetadata: ApiRequestMetadata;
};
export const UNSAFE_updateEnvelopeItems = async ({
envelopeId,
envelopeType,
existingEnvelopeItems,
data,
user,
apiRequestMetadata,
}: UnsafeUpdateEnvelopeItemsOptions) => {
const updatedEnvelopeItems = await Promise.all(
data.map(async ({ envelopeItemId, order, title }) =>
prisma.envelopeItem.update({
where: {
envelopeId,
id: envelopeItemId,
},
data: {
order,
title,
},
select: {
id: true,
order: true,
title: true,
envelopeId: true,
},
}),
),
);
// Write audit logs for DOCUMENT type envelopes when changes are detected.
if (envelopeType === 'DOCUMENT') {
const auditLogs = data.flatMap((item) => {
const existing = existingEnvelopeItems.find((e) => e.id === item.envelopeItemId);
if (!existing) {
return [];
}
const changes: { field: string; from: string; to: string }[] = [];
if (item.title !== undefined && item.title !== existing.title) {
changes.push({
field: 'title',
from: existing.title,
to: item.title,
});
}
if (changes.length === 0) {
return [];
}
return [
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.ENVELOPE_ITEM_UPDATED,
envelopeId,
data: {
envelopeItemId: item.envelopeItemId,
changes,
},
user: {
name: user.name,
email: user.email,
},
requestMetadata: apiRequestMetadata.requestMetadata,
}),
];
});
if (auditLogs.length > 0) {
await prisma.documentAuditLog.createMany({
data: auditLogs,
});
}
}
return updatedEnvelopeItems;
};
@@ -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;
};
@@ -1,6 +1,3 @@
import { PDF } from '@libpdf/core';
import { EnvelopeType } from '@prisma/client';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { TFieldAndMeta } from '@documenso/lib/types/field-meta';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
@@ -8,6 +5,8 @@ import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.serv
import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import { PDF } from '@libpdf/core';
import { EnvelopeType } from '@prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { EnvelopeIdOptions } from '../../utils/envelope';
@@ -157,8 +156,7 @@ export const createEnvelopeFields = async ({
// Check whether the recipient associated with the field can have new fields created.
if (!canRecipientFieldsBeModified(recipient, envelope.fields)) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message:
'Recipient type cannot have fields, or they have already interacted with the document.',
message: 'Recipient type cannot have fields, or they have already interacted with the document.',
});
}
@@ -265,9 +263,7 @@ export const createEnvelopeFields = async ({
if (envelope.type === EnvelopeType.DOCUMENT) {
await tx.documentAuditLog.createMany({
data: newlyCreatedFields.map((createdField) => {
const recipient = validatedFields.find(
(field) => field.recipientId === createdField.recipientId,
);
const recipient = validatedFields.find((field) => field.recipientId === createdField.recipientId);
return createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_CREATED,
@@ -308,7 +304,7 @@ export const createEnvelopeFields = async ({
continue;
}
const newDocumentData = await putPdfFileServerSide({
const { documentData: newDocumentData } = await putPdfFileServerSide({
name: 'document.pdf',
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(Buffer.from(modifiedPdfBytes)),
@@ -1,9 +1,8 @@
import { EnvelopeType } from '@prisma/client';
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';
import { prisma } from '@documenso/prisma';
import { EnvelopeType } from '@prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { canRecipientFieldsBeModified } from '../../utils/recipients';
@@ -16,12 +15,7 @@ export interface DeleteDocumentFieldOptions {
requestMetadata: ApiRequestMetadata;
}
export const deleteDocumentField = async ({
userId,
teamId,
fieldId,
requestMetadata,
}: DeleteDocumentFieldOptions) => {
export const deleteDocumentField = async ({ userId, teamId, fieldId, requestMetadata }: DeleteDocumentFieldOptions) => {
// Unauthenticated check, we do the real check later.
const field = await prisma.field.findFirst({
where: {
@@ -1,6 +1,5 @@
import { EnvelopeType } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { EnvelopeType } from '@prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { buildTeamWhereQuery } from '../../utils/teams';
@@ -12,11 +11,7 @@ export interface DeleteTemplateFieldOptions {
fieldId: number;
}
export const deleteTemplateField = async ({
userId,
teamId,
fieldId,
}: DeleteTemplateFieldOptions): Promise<void> => {
export const deleteTemplateField = async ({ userId, teamId, fieldId }: DeleteTemplateFieldOptions): Promise<void> => {
const field = await prisma.field.findFirst({
where: {
id: fieldId,
@@ -1,14 +1,11 @@
import { SigningStatus } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { SigningStatus } from '@prisma/client';
export type GetCompletedFieldsForDocumentOptions = {
documentId: number;
};
export const getCompletedFieldsForDocument = async ({
documentId,
}: GetCompletedFieldsForDocumentOptions) => {
export const getCompletedFieldsForDocument = async ({ documentId }: GetCompletedFieldsForDocumentOptions) => {
return await prisma.field.findMany({
where: {
documentId,
@@ -1,6 +1,5 @@
import { EnvelopeType, SigningStatus } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { EnvelopeType, SigningStatus } from '@prisma/client';
export type GetCompletedFieldsForTokenOptions = {
token: string;
@@ -1,6 +1,5 @@
import type { EnvelopeType } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import type { EnvelopeType } from '@prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { mapFieldToLegacyField } from '../../utils/fields';
@@ -14,12 +13,7 @@ export type GetFieldByIdOptions = {
envelopeType?: EnvelopeType;
};
export const getFieldById = async ({
userId,
teamId,
fieldId,
envelopeType,
}: GetFieldByIdOptions) => {
export const getFieldById = async ({ userId, teamId, fieldId, envelopeType }: GetFieldByIdOptions) => {
const field = await prisma.field.findFirst({
where: {
id: fieldId,
@@ -1,6 +1,5 @@
import { EnvelopeType, FieldType, RecipientRole, SigningStatus } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { EnvelopeType, FieldType, RecipientRole, SigningStatus } from '@prisma/client';
export type GetFieldsForTokenOptions = {
token: string;
@@ -35,6 +34,7 @@ export const getFieldsForToken = async ({ token }: GetFieldsForTokenOptions) =>
signingOrder: {
gte: recipient.signingOrder ?? 0,
},
envelopeId: recipient.envelopeId,
},
envelope: {
id: recipient.envelopeId,
@@ -1,9 +1,9 @@
import { DocumentStatus, RecipientRole, SigningStatus } 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';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { assertRecipientNotExpired } from '@documenso/lib/utils/recipients';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
export type RemovedSignedFieldWithTokenOptions = {
token: string;
@@ -37,6 +37,7 @@ export const removeSignedFieldWithToken = async ({
signingStatus: {
not: SigningStatus.SIGNED,
},
envelopeId: recipient.envelopeId,
}),
},
},
@@ -56,10 +57,9 @@ export const removeSignedFieldWithToken = async ({
throw new Error(`Document ${envelope.id} must be pending`);
}
if (
recipient?.signingStatus === SigningStatus.SIGNED ||
field.recipient.signingStatus === SigningStatus.SIGNED
) {
assertRecipientNotExpired(recipient);
if (recipient?.signingStatus === SigningStatus.SIGNED || field.recipient.signingStatus === SigningStatus.SIGNED) {
throw new Error(`Recipient ${recipient.id} has already signed`);
}
@@ -1,6 +1,3 @@
import { EnvelopeType, type Field, FieldType } from '@prisma/client';
import { isDeepEqual } from 'remeda';
import { validateCheckboxField } from '@documenso/lib/advanced-fields-validation/validate-checkbox';
import { validateDropdownField } from '@documenso/lib/advanced-fields-validation/validate-dropdown';
import { validateNumberField } from '@documenso/lib/advanced-fields-validation/validate-number';
@@ -8,6 +5,7 @@ import { validateRadioField } from '@documenso/lib/advanced-fields-validation/va
import { validateTextField } from '@documenso/lib/advanced-fields-validation/validate-text';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import {
FIELD_META_DEFAULT_VALUES,
type TFieldMetaSchema as FieldMeta,
ZCheckboxFieldMeta,
ZDropdownFieldMeta,
@@ -17,11 +15,10 @@ import {
ZTextFieldMeta,
} from '@documenso/lib/types/field-meta';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import {
createDocumentAuditLogData,
diffFieldChanges,
} from '@documenso/lib/utils/document-audit-logs';
import { createDocumentAuditLogData, diffFieldChanges } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import { EnvelopeType, type Field, FieldType } from '@prisma/client';
import { isDeepEqual } from 'remeda';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { EnvelopeIdOptions } from '../../utils/envelope';
@@ -92,9 +89,7 @@ export const setFieldsForDocument = async ({
const recipient = envelope.recipients.find((recipient) => recipient.id === field.recipientId);
// Check whether the field is being attached to an allowed envelope item.
const foundEnvelopeItem = envelope.envelopeItems.find(
(envelopeItem) => envelopeItem.id === field.envelopeItemId,
);
const foundEnvelopeItem = envelope.envelopeItems.find((envelopeItem) => envelopeItem.id === field.envelopeItemId);
if (!foundEnvelopeItem) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
@@ -110,22 +105,16 @@ export const setFieldsForDocument = async ({
}
// Check whether the existing field can be modified.
if (
existing &&
hasFieldBeenChanged(existing, field) &&
!canRecipientFieldsBeModified(recipient, existingFields)
) {
if (existing && hasFieldBeenChanged(existing, field) && !canRecipientFieldsBeModified(recipient, existingFields)) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message:
'Cannot modify a field where the recipient has already interacted with the document',
message: 'Cannot modify a field where the recipient has already interacted with the document',
});
}
// Prevent creating new fields when recipient has interacted with the document.
if (!existing && !canRecipientFieldsBeModified(recipient, existingFields)) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message:
'Cannot modify a field where the recipient has already interacted with the document',
message: 'Cannot modify a field where the recipient has already interacted with the document',
});
}
@@ -143,7 +132,7 @@ export const setFieldsForDocument = async ({
const parsedFieldMeta = field.fieldMeta
? ZFieldMetaSchema.parse(field.fieldMeta)
: undefined;
: FIELD_META_DEFAULT_VALUES[field.type];
if (field.type === FieldType.TEXT && field.fieldMeta) {
const textFieldParsedMeta = ZTextFieldMeta.parse(field.fieldMeta);
@@ -157,11 +146,7 @@ export const setFieldsForDocument = async ({
if (field.type === FieldType.NUMBER && field.fieldMeta) {
const numberFieldParsedMeta = ZNumberFieldMeta.parse(field.fieldMeta);
const errors = validateNumberField(
String(numberFieldParsedMeta.value || ''),
numberFieldParsedMeta,
false,
);
const errors = validateNumberField(String(numberFieldParsedMeta.value || ''), numberFieldParsedMeta, false);
if (errors.length > 0) {
throw new Error(errors.join(', '));
@@ -180,18 +165,14 @@ export const setFieldsForDocument = async ({
throw new Error(errors.join(', '));
}
} else {
throw new Error(
'To proceed further, please set at least one value for the Checkbox field',
);
throw new Error('To proceed further, please set at least one value for the Checkbox field');
}
}
if (field.type === FieldType.RADIO) {
if (field.fieldMeta) {
const radioFieldParsedMeta = ZRadioFieldMeta.parse(field.fieldMeta);
const checkedRadioFieldValue = radioFieldParsedMeta.values?.find(
(option) => option.checked,
)?.value;
const checkedRadioFieldValue = radioFieldParsedMeta.values?.find((option) => option.checked)?.value;
const errors = validateRadioField(checkedRadioFieldValue, radioFieldParsedMeta);
@@ -199,9 +180,7 @@ export const setFieldsForDocument = async ({
throw new Error(errors.join('. '));
}
} else {
throw new Error(
'To proceed further, please set at least one value for the Radio field',
);
throw new Error('To proceed further, please set at least one value for the Radio field');
}
}
@@ -214,9 +193,7 @@ export const setFieldsForDocument = async ({
throw new Error(errors.join('. '));
}
} else {
throw new Error(
'To proceed further, please set at least one value for the Dropdown field',
);
throw new Error('To proceed further, please set at least one value for the Dropdown field');
}
}
@@ -1,11 +1,10 @@
import { EnvelopeType, FieldType } from '@prisma/client';
import { validateCheckboxField } from '@documenso/lib/advanced-fields-validation/validate-checkbox';
import { validateDropdownField } from '@documenso/lib/advanced-fields-validation/validate-dropdown';
import { validateNumberField } from '@documenso/lib/advanced-fields-validation/validate-number';
import { validateRadioField } from '@documenso/lib/advanced-fields-validation/validate-radio';
import { validateTextField } from '@documenso/lib/advanced-fields-validation/validate-text';
import {
FIELD_META_DEFAULT_VALUES,
type TFieldMetaSchema as FieldMeta,
ZCheckboxFieldMeta,
ZDropdownFieldMeta,
@@ -15,6 +14,7 @@ import {
ZTextFieldMeta,
} from '@documenso/lib/types/field-meta';
import { prisma } from '@documenso/prisma';
import { EnvelopeType, FieldType } from '@prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { EnvelopeIdOptions } from '../../utils/envelope';
@@ -40,12 +40,7 @@ export type SetFieldsForTemplateOptions = {
}[];
};
export const setFieldsForTemplate = async ({
userId,
teamId,
id,
fields,
}: SetFieldsForTemplateOptions) => {
export const setFieldsForTemplate = async ({ userId, teamId, id, fields }: SetFieldsForTemplateOptions) => {
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id,
type: EnvelopeType.TEMPLATE,
@@ -88,9 +83,7 @@ export const setFieldsForTemplate = async ({
const recipient = envelope.recipients.find((recipient) => recipient.id === field.recipientId);
// Check whether the field is being attached to an allowed envelope item.
const foundEnvelopeItem = envelope.envelopeItems.find(
(envelopeItem) => envelopeItem.id === field.envelopeItemId,
);
const foundEnvelopeItem = envelope.envelopeItems.find((envelopeItem) => envelopeItem.id === field.envelopeItemId);
if (!foundEnvelopeItem) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
@@ -116,7 +109,9 @@ export const setFieldsForTemplate = async ({
// Disabling as wrapping promises here causes type issues
// eslint-disable-next-line @typescript-eslint/promise-function-async
linkedFields.map(async (field) => {
const parsedFieldMeta = field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined;
const parsedFieldMeta = field.fieldMeta
? ZFieldMetaSchema.parse(field.fieldMeta)
: FIELD_META_DEFAULT_VALUES[field.type];
if (field.type === FieldType.TEXT && field.fieldMeta) {
const textFieldParsedMeta = ZTextFieldMeta.parse(field.fieldMeta);
@@ -128,10 +123,7 @@ export const setFieldsForTemplate = async ({
if (field.type === FieldType.NUMBER && field.fieldMeta) {
const numberFieldParsedMeta = ZNumberFieldMeta.parse(field.fieldMeta);
const errors = validateNumberField(
String(numberFieldParsedMeta.value || ''),
numberFieldParsedMeta,
);
const errors = validateNumberField(String(numberFieldParsedMeta.value || ''), numberFieldParsedMeta);
if (errors.length > 0) {
throw new Error(errors.join(', '));
}
@@ -156,9 +148,7 @@ export const setFieldsForTemplate = async ({
throw new Error('Radio field is missing required metadata');
}
const radioFieldParsedMeta = ZRadioFieldMeta.parse(field.fieldMeta);
const checkedRadioFieldValue = radioFieldParsedMeta.values?.find(
(option) => option.checked,
)?.value;
const checkedRadioFieldValue = radioFieldParsedMeta.values?.find((option) => option.checked)?.value;
const errors = validateRadioField(checkedRadioFieldValue, radioFieldParsedMeta);
if (errors.length > 0) {
throw new Error(errors.join('. '));
@@ -1,8 +1,3 @@
import { DocumentStatus, FieldType, RecipientRole, SigningStatus } from '@prisma/client';
import { DateTime } from 'luxon';
import { isDeepEqual } from 'remeda';
import { match } from 'ts-pattern';
import { validateCheckboxField } from '@documenso/lib/advanced-fields-validation/validate-checkbox';
import { validateDropdownField } from '@documenso/lib/advanced-fields-validation/validate-dropdown';
import { validateNumberField } from '@documenso/lib/advanced-fields-validation/validate-number';
@@ -10,6 +5,10 @@ import { validateRadioField } from '@documenso/lib/advanced-fields-validation/va
import { validateTextField } from '@documenso/lib/advanced-fields-validation/validate-text';
import { fromCheckboxValue } from '@documenso/lib/universal/field-checkbox';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, FieldType, RecipientRole, SigningStatus } from '@prisma/client';
import { DateTime } from 'luxon';
import { isDeepEqual } from 'remeda';
import { match } from 'ts-pattern';
import { AUTO_SIGNABLE_FIELD_TYPES } from '../../constants/autosign';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../../constants/date-formats';
@@ -25,6 +24,7 @@ import {
} from '../../types/field-meta';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { assertRecipientNotExpired } from '../../utils/recipients';
import { validateFieldAuth } from '../document/validate-field-auth';
export type SignFieldWithTokenOptions = {
@@ -77,6 +77,7 @@ export const signFieldWithToken = async ({
signingOrder: {
gte: recipient.signingOrder ?? 0,
},
envelopeId: recipient.envelopeId,
}),
},
},
@@ -108,10 +109,9 @@ export const signFieldWithToken = async ({
throw new Error(`Document ${envelope.id} must be pending for signing`);
}
if (
recipient.signingStatus === SigningStatus.SIGNED ||
field.recipient.signingStatus === SigningStatus.SIGNED
) {
assertRecipientNotExpired(recipient);
if (recipient.signingStatus === SigningStatus.SIGNED || field.recipient.signingStatus === SigningStatus.SIGNED) {
throw new Error(`Recipient ${recipient.id} has already signed`);
}
@@ -188,8 +188,7 @@ export const signFieldWithToken = async ({
},
});
const isSignatureField =
field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE;
const isSignatureField = field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE;
let customText = !isSignatureField ? value : undefined;
@@ -292,27 +291,14 @@ export const signFieldWithToken = async ({
type,
data: signatureImageAsBase64 || typedSignature || '',
}))
.with(
FieldType.DATE,
FieldType.EMAIL,
FieldType.NAME,
FieldType.TEXT,
FieldType.INITIALS,
(type) => ({
type,
data: updatedField.customText,
}),
)
.with(
FieldType.NUMBER,
FieldType.RADIO,
FieldType.CHECKBOX,
FieldType.DROPDOWN,
(type) => ({
type,
data: updatedField.customText,
}),
)
.with(FieldType.DATE, FieldType.EMAIL, FieldType.NAME, FieldType.TEXT, FieldType.INITIALS, (type) => ({
type,
data: updatedField.customText,
}))
.with(FieldType.NUMBER, FieldType.RADIO, FieldType.CHECKBOX, FieldType.DROPDOWN, (type) => ({
type,
data: updatedField.customText,
}))
.exhaustive(),
fieldSecurity: derivedRecipientActionAuth
? {
@@ -1,16 +1,12 @@
import { EnvelopeType, type FieldType } from '@prisma/client';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { TFieldMetaSchema } from '@documenso/lib/types/field-meta';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import {
createDocumentAuditLogData,
diffFieldChanges,
} from '@documenso/lib/utils/document-audit-logs';
import { createDocumentAuditLogData, diffFieldChanges } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import { EnvelopeType, type FieldType } from '@prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { type EnvelopeIdOptions } from '../../utils/envelope';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { mapFieldToLegacyField } from '../../utils/fields';
import { canRecipientFieldsBeModified } from '../../utils/recipients';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
@@ -79,9 +75,7 @@ export const updateEnvelopeFields = async ({
});
}
const recipient = envelope.recipients.find(
(recipient) => recipient.id === originalField.recipientId,
);
const recipient = envelope.recipients.find((recipient) => recipient.id === originalField.recipientId);
// Each field MUST have a recipient associated with it.
if (!recipient) {
@@ -93,8 +87,7 @@ export const updateEnvelopeFields = async ({
// Check whether the recipient associated with the field can be modified.
if (!canRecipientFieldsBeModified(recipient, envelope.fields)) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message:
'Cannot modify a field where the recipient has already interacted with the document',
message: 'Cannot modify a field where the recipient has already interacted with the document',
});
}
@@ -102,20 +95,13 @@ export const updateEnvelopeFields = async ({
const fieldMetaType = field.fieldMeta?.type || originalField.fieldMeta?.type;
// Not going to mess with V1 envelopes.
if (
envelope.internalVersion === 2 &&
fieldMetaType &&
fieldMetaType.toLowerCase() !== fieldType.toLowerCase()
) {
if (envelope.internalVersion === 2 && fieldMetaType && fieldMetaType.toLowerCase() !== fieldType.toLowerCase()) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Field meta type does not match the field type',
});
}
if (
field.envelopeItemId &&
!envelope.envelopeItems.some((item) => item.id === field.envelopeItemId)
) {
if (field.envelopeItemId && !envelope.envelopeItems.some((item) => item.id === field.envelopeItemId)) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Envelope item not found',
});
@@ -1,6 +1,5 @@
import { EnvelopeType } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { EnvelopeType } from '@prisma/client';
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
import type { TFolderType } from '../../types/folder-type';
@@ -13,12 +12,7 @@ export interface FindFoldersInternalOptions {
type?: TFolderType;
}
export const findFoldersInternal = async ({
userId,
teamId,
parentId,
type,
}: FindFoldersInternalOptions) => {
export const findFoldersInternal = async ({ userId, teamId, parentId, type }: FindFoldersInternalOptions) => {
const team = await getTeamById({ userId, teamId });
const visibilityFilters = {
@@ -1,6 +1,5 @@
import type { Prisma } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import type { Prisma } from '@prisma/client';
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
import type { TFolderType } from '../../types/folder-type';
@@ -17,14 +16,7 @@ export interface FindFoldersOptions {
perPage?: number;
}
export const findFolders = async ({
userId,
teamId,
parentId,
type,
page = 1,
perPage = 10,
}: FindFoldersOptions) => {
export const findFolders = async ({ userId, teamId, parentId, type, page = 1, perPage = 10 }: FindFoldersOptions) => {
const team = await getTeamById({ userId, teamId });
const whereClause: Prisma.FolderWhereInput = {
@@ -1,8 +1,7 @@
import { prisma } from '@documenso/prisma';
import { TeamMemberRole } from '@prisma/client';
import { match } from 'ts-pattern';
import { prisma } from '@documenso/prisma';
import { DocumentVisibility } from '../../types/document-visibility';
import type { TFolderType } from '../../types/folder-type';
import { getTeamById } from '../team/get-team';
@@ -14,22 +13,13 @@ export interface GetFolderBreadcrumbsOptions {
type?: TFolderType;
}
export const getFolderBreadcrumbs = async ({
userId,
teamId,
folderId,
type,
}: GetFolderBreadcrumbsOptions) => {
export const getFolderBreadcrumbs = async ({ userId, teamId, folderId, type }: GetFolderBreadcrumbsOptions) => {
const team = await getTeamById({ userId, teamId });
const visibilityFilters = match(team.currentTeamRole)
.with(TeamMemberRole.ADMIN, () => ({
visibility: {
in: [
DocumentVisibility.EVERYONE,
DocumentVisibility.MANAGER_AND_ABOVE,
DocumentVisibility.ADMIN,
],
in: [DocumentVisibility.EVERYONE, DocumentVisibility.MANAGER_AND_ABOVE, DocumentVisibility.ADMIN],
},
}))
.with(TeamMemberRole.MANAGER, () => ({
@@ -9,7 +9,7 @@ import {
NEXT_PUBLIC_WEBAPP_URL,
USE_INTERNAL_URL_BROWSERLESS,
} from '../../constants/app';
import { type SupportedLanguageCodes, isValidLanguageCode } from '../../constants/i18n';
import { isValidLanguageCode, type SupportedLanguageCodes } from '../../constants/i18n';
import { env } from '../../utils/env';
import { encryptSecondaryData } from '../crypto/encrypt';
@@ -57,9 +57,7 @@ export const getAuditLogsPdf = async ({ documentId, language }: GetAuditLogsPdfO
{
name: 'language',
value: lang,
url: USE_INTERNAL_URL_BROWSERLESS()
? NEXT_PUBLIC_WEBAPP_URL()
: NEXT_PRIVATE_INTERNAL_WEBAPP_URL(),
url: USE_INTERNAL_URL_BROWSERLESS() ? NEXT_PUBLIC_WEBAPP_URL() : NEXT_PRIVATE_INTERNAL_WEBAPP_URL(),
},
]);
@@ -9,7 +9,7 @@ import {
NEXT_PUBLIC_WEBAPP_URL,
USE_INTERNAL_URL_BROWSERLESS,
} from '../../constants/app';
import { type SupportedLanguageCodes, isValidLanguageCode } from '../../constants/i18n';
import { isValidLanguageCode, type SupportedLanguageCodes } from '../../constants/i18n';
import { env } from '../../utils/env';
import { encryptSecondaryData } from '../crypto/encrypt';
@@ -57,9 +57,7 @@ export const getCertificatePdf = async ({ documentId, language }: GetCertificate
{
name: 'lang',
value: lang,
url: USE_INTERNAL_URL_BROWSERLESS()
? NEXT_PUBLIC_WEBAPP_URL()
: NEXT_PRIVATE_INTERNAL_WEBAPP_URL(),
url: USE_INTERNAL_URL_BROWSERLESS() ? NEXT_PUBLIC_WEBAPP_URL() : NEXT_PRIVATE_INTERNAL_WEBAPP_URL(),
},
]);

Some files were not shown because too many files have changed in this diff Show More