chore: merge main, resolve biome formatting conflicts

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

In complete-document-with-token.ts, drop the duplicate early
field-fetching block introduced when main moved that logic later
with date auto-insert support; keep the EXTERNAL_TWO_FACTOR_AUTH
check using derivedRecipientActionAuth.
This commit is contained in:
ephraimduncan
2026-05-12 11:46:11 +00:00
parent 9194884fbe
commit 138d663c25
1959 changed files with 93488 additions and 47038 deletions
@@ -1,6 +1,6 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import { nanoid, prefixedId } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import type { TSignFieldWithTokenMutationSchema } from '@documenso/trpc/server/field-router/schema';
import type { Field, Signature } from '@prisma/client';
import {
DocumentSigningOrder,
@@ -18,23 +18,14 @@ import { DateTime } from 'luxon';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { mailer } from '@documenso/email/mailer';
import { DocumentCreatedFromDirectTemplateEmailTemplate } from '@documenso/email/templates/document-created-from-direct-template';
import { nanoid, prefixedId } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import type { TSignFieldWithTokenMutationSchema } from '@documenso/trpc/server/field-router/schema';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { jobs } from '../../jobs/client';
import { DOCUMENT_AUDIT_LOG_TYPE, RECIPIENT_DIFF_TYPE } from '../../types/document-audit-logs';
import type { TRecipientActionAuthTypes } from '../../types/document-auth';
import { DocumentAccessAuth, ZRecipientAuthOptionsSchema } from '../../types/document-auth';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { ZFieldMetaSchema } from '../../types/field-meta';
import {
ZWebhookDocumentSchema,
mapEnvelopeToWebhookDocumentPayload,
} from '../../types/webhook-payload';
import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../types/webhook-payload';
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
import { getFileServerSide } from '../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
@@ -48,12 +39,10 @@ import {
extractDocumentAuthMethods,
} from '../../utils/document-auth';
import { mapSecondaryIdToTemplateId } from '../../utils/envelope';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { formatDocumentsPath } from '../../utils/teams';
import { sendDocument } from '../document/send-document';
import { validateFieldAuth } from '../document/validate-field-auth';
import { getEmailContext } from '../email/get-email-context';
import { incrementDocumentId } from '../envelope/increment-id';
import { getTeamSettings } from '../team/get-team-settings';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export type CreateDocumentFromDirectTemplateOptions = {
@@ -87,9 +76,7 @@ export const ZCreateDocumentFromDirectTemplateResponseSchema = z.object({
recipientId: z.number(),
});
export type TCreateDocumentFromDirectTemplateResponse = z.infer<
typeof ZCreateDocumentFromDirectTemplateResponseSchema
>;
export type TCreateDocumentFromDirectTemplateResponse = z.infer<typeof ZCreateDocumentFromDirectTemplateResponseSchema>;
export const createDocumentFromDirectTemplate = async ({
directRecipientName: initialDirectRecipientName,
@@ -141,14 +128,11 @@ export const createDocumentFromDirectTemplate = async ({
directTemplateEnvelope.documentMeta?.signingOrder !== DocumentSigningOrder.SEQUENTIAL)
) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message:
'You need to enable allowDictateNextSigner and sequential signing to dictate the next signer',
message: 'You need to enable allowDictateNextSigner and sequential signing to dictate the next signer',
});
}
const directTemplateEnvelopeLegacyId = mapSecondaryIdToTemplateId(
directTemplateEnvelope.secondaryId,
);
const directTemplateEnvelopeLegacyId = mapSecondaryIdToTemplateId(directTemplateEnvelope.secondaryId);
if (directTemplateEnvelope.envelopeItems.length < 1) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
@@ -156,19 +140,14 @@ export const createDocumentFromDirectTemplate = async ({
});
}
const { branding, settings, senderEmail, emailLanguage } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'team',
teamId: directTemplateEnvelope.teamId,
},
const settings = await getTeamSettings({
userId: directTemplateEnvelope.userId,
teamId: directTemplateEnvelope.teamId,
});
const { recipients, directLink, user: templateOwner } = directTemplateEnvelope;
const { recipients, directLink } = directTemplateEnvelope;
const directTemplateRecipient = recipients.find(
(recipient) => recipient.id === directLink.directTemplateRecipientId,
);
const directTemplateRecipient = recipients.find((recipient) => recipient.id === directLink.directTemplateRecipientId);
if (!directTemplateRecipient || directTemplateRecipient.role === RecipientRole.CC) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
@@ -180,10 +159,9 @@ export const createDocumentFromDirectTemplate = async ({
throw new AppError(AppErrorCode.INVALID_REQUEST, { message: 'Template no longer matches' });
}
const { derivedRecipientAccessAuth, documentAuthOption: templateAuthOptions } =
extractDocumentAuthMethods({
documentAuth: directTemplateEnvelope.authOptions,
});
const { derivedRecipientAccessAuth, documentAuthOption: templateAuthOptions } = extractDocumentAuthMethods({
documentAuth: directTemplateEnvelope.authOptions,
});
let directRecipientName = user?.name || initialDirectRecipientName;
@@ -198,17 +176,12 @@ export const createDocumentFromDirectTemplate = async ({
throw new AppError(AppErrorCode.UNAUTHORIZED, { message: 'You must be logged in' });
}
const directTemplateRecipientAuthOptions = ZRecipientAuthOptionsSchema.parse(
directTemplateRecipient.authOptions,
);
const directTemplateRecipientAuthOptions = ZRecipientAuthOptionsSchema.parse(directTemplateRecipient.authOptions);
const nonDirectTemplateRecipients = directTemplateEnvelope.recipients.filter(
(recipient) => recipient.id !== directTemplateRecipient.id,
);
const derivedDocumentMeta = extractDerivedDocumentMeta(
settings,
directTemplateEnvelope.documentMeta,
);
const derivedDocumentMeta = extractDerivedDocumentMeta(settings, directTemplateEnvelope.documentMeta);
// Associate, validate and map to a query every direct template recipient field with the provided fields.
// Only process fields that are either required or have been signed by the user
@@ -227,9 +200,7 @@ export const createDocumentFromDirectTemplate = async ({
const createDirectRecipientFieldArgs = await Promise.all(
fieldsToProcess.map(async (templateField) => {
const signedFieldValue = signedFieldValues.find(
(value) => value.fieldId === templateField.id,
);
const signedFieldValue = signedFieldValues.find((value) => value.fieldId === templateField.id);
if (isRequiredField(templateField) && !signedFieldValue) {
throw new AppError(AppErrorCode.INVALID_BODY, {
@@ -265,8 +236,7 @@ export const createDocumentFromDirectTemplate = async ({
const { value, isBase64 } = signedFieldValue;
const isSignatureField =
templateField.type === FieldType.SIGNATURE ||
templateField.type === FieldType.FREE_SIGNATURE;
templateField.type === FieldType.SIGNATURE || templateField.type === FieldType.FREE_SIGNATURE;
let customText = !isSignatureField ? value : '';
@@ -274,9 +244,7 @@ export const createDocumentFromDirectTemplate = async ({
const typedSignature = isSignatureField && !isBase64 ? value : undefined;
if (templateField.type === FieldType.DATE) {
customText = DateTime.now()
.setZone(derivedDocumentMeta.timezone)
.toFormat(derivedDocumentMeta.dateFormat);
customText = DateTime.now().setZone(derivedDocumentMeta.timezone).toFormat(derivedDocumentMeta.dateFormat);
}
if (isSignatureField && !signatureImageAsBase64 && !typedSignature) {
@@ -297,13 +265,9 @@ export const createDocumentFromDirectTemplate = async ({
}),
);
const directTemplateNonSignatureFields = createDirectRecipientFieldArgs.filter(
({ signature }) => signature === null,
);
const directTemplateNonSignatureFields = createDirectRecipientFieldArgs.filter(({ signature }) => signature === null);
const directTemplateSignatureFields = createDirectRecipientFieldArgs.filter(
({ signature }) => signature !== null,
);
const directTemplateSignatureFields = createDirectRecipientFieldArgs.filter(({ signature }) => signature !== null);
const initialRequestTime = new Date();
@@ -318,20 +282,12 @@ export const createDocumentFromDirectTemplate = async ({
const titleToUse = item.title || directTemplateEnvelope.title;
const duplicatedFile = await putPdfFileServerSide({
const { documentData: newDocumentData } = await putPdfFileServerSide({
name: titleToUse,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(buffer),
});
const newDocumentData = await prisma.documentData.create({
data: {
type: duplicatedFile.type,
data: duplicatedFile.data,
initialData: duplicatedFile.initialData,
},
});
const newEnvelopeItemId = prefixedId('envelope_item');
oldEnvelopeItemToNewEnvelopeItemIdMap[item.id] = newEnvelopeItemId;
@@ -391,12 +347,8 @@ export const createDocumentFromDirectTemplate = async ({
accessAuth: authOptions.accessAuth,
actionAuth: authOptions.actionAuth,
}),
sendStatus:
recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT,
signingStatus:
recipient.role === RecipientRole.CC
? SigningStatus.SIGNED
: SigningStatus.NOT_SIGNED,
sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT,
signingStatus: recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
signingOrder: recipient.signingOrder,
token: nanoid(),
};
@@ -423,9 +375,7 @@ export const createDocumentFromDirectTemplate = async ({
let nonDirectRecipientFieldsToCreate: Omit<Field, 'id' | 'secondaryId' | 'templateId'>[] = [];
Object.values(nonDirectTemplateRecipients).forEach((templateRecipient) => {
const recipient = createdEnvelope.recipients.find(
(recipient) => recipient.email === templateRecipient.email,
);
const recipient = createdEnvelope.recipients.find((recipient) => recipient.email === templateRecipient.email);
if (!recipient) {
throw new Error('Recipient not found.');
@@ -507,45 +457,43 @@ export const createDocumentFromDirectTemplate = async ({
// Create any direct recipient signature fields.
// Note: It's done like this because we can't nest things in createMany.
const createdDirectRecipientSignatureFields: CreatedDirectRecipientField[] = await Promise.all(
directTemplateSignatureFields.map(
async ({ templateField, signature, derivedRecipientActionAuth }) => {
if (!signature) {
throw new Error('Not possible.');
}
directTemplateSignatureFields.map(async ({ templateField, signature, derivedRecipientActionAuth }) => {
if (!signature) {
throw new Error('Not possible.');
}
const field = await tx.field.create({
data: {
envelopeId: createdEnvelope.id,
envelopeItemId: oldEnvelopeItemToNewEnvelopeItemIdMap[templateField.envelopeItemId],
recipientId: createdDirectRecipient.id,
type: templateField.type,
page: templateField.page,
positionX: templateField.positionX,
positionY: templateField.positionY,
width: templateField.width,
height: templateField.height,
customText: '',
inserted: true,
fieldMeta: templateField.fieldMeta || Prisma.JsonNull,
signature: {
create: {
recipientId: createdDirectRecipient.id,
signatureImageAsBase64: signature.signatureImageAsBase64,
typedSignature: signature.typedSignature,
},
const field = await tx.field.create({
data: {
envelopeId: createdEnvelope.id,
envelopeItemId: oldEnvelopeItemToNewEnvelopeItemIdMap[templateField.envelopeItemId],
recipientId: createdDirectRecipient.id,
type: templateField.type,
page: templateField.page,
positionX: templateField.positionX,
positionY: templateField.positionY,
width: templateField.width,
height: templateField.height,
customText: '',
inserted: true,
fieldMeta: templateField.fieldMeta || Prisma.JsonNull,
signature: {
create: {
recipientId: createdDirectRecipient.id,
signatureImageAsBase64: signature.signatureImageAsBase64,
typedSignature: signature.typedSignature,
},
},
include: {
signature: true,
},
});
},
include: {
signature: true,
},
});
return {
field,
derivedRecipientActionAuth,
};
},
),
return {
field,
derivedRecipientActionAuth,
};
}),
);
const createdDirectRecipientFields: CreatedDirectRecipientField[] = [
@@ -617,8 +565,7 @@ export const createDocumentFromDirectTemplate = async ({
field: match(field.type)
.with(FieldType.SIGNATURE, FieldType.FREE_SIGNATURE, (type) => ({
type,
data:
field.signature?.signatureImageAsBase64 || field.signature?.typedSignature || '',
data: field.signature?.signatureImageAsBase64 || field.signature?.typedSignature || '',
}))
.with(
FieldType.DATE,
@@ -723,6 +670,7 @@ export const createDocumentFromDirectTemplate = async ({
where: { id: nextRecipient.id },
data: {
sendStatus: SendStatus.SENT,
sentAt: new Date(),
...(nextSigner && documentMeta?.allowDictateNextSigner
? {
name: nextSigner.name,
@@ -755,37 +703,6 @@ export const createDocumentFromDirectTemplate = async ({
});
}
// Send email to template owner.
const emailTemplate = createElement(DocumentCreatedFromDirectTemplateEmailTemplate, {
recipientName: directRecipientEmail,
recipientRole: directTemplateRecipient.role,
documentLink: `${NEXT_PUBLIC_WEBAPP_URL()}${formatDocumentsPath(createdEnvelope.team?.url)}/${
createdEnvelope.id
}`,
documentName: createdEnvelope.title,
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000',
});
const [html, text] = await Promise.all([
renderEmailWithI18N(emailTemplate, { lang: emailLanguage, branding }),
renderEmailWithI18N(emailTemplate, { lang: emailLanguage, branding, plainText: true }),
]);
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
to: [
{
name: templateOwner.name || '',
address: templateOwner.email,
},
],
from: senderEmail,
subject: i18n._(msg`Document created from direct template`),
html,
text,
});
return {
createdEnvelope,
token: createdDirectRecipient.token,
@@ -793,6 +710,18 @@ export const createDocumentFromDirectTemplate = async ({
};
});
const emailSettings = extractDerivedDocumentEmailSettings(documentMeta);
if (emailSettings.ownerDocumentCreated) {
await jobs.triggerJob({
name: 'send.document.created.from.direct.template.email',
payload: {
envelopeId: createdEnvelope.id,
recipientId,
},
});
}
try {
// This handles sending emails and sealing the document if required.
await sendDocument({
@@ -1,3 +1,5 @@
import { nanoid, prefixedId } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import type { DocumentDistributionMethod, DocumentSigningOrder } from '@prisma/client';
import {
DocumentSource,
@@ -13,10 +15,8 @@ import {
import { DateTime } from 'luxon';
import { match } from 'ts-pattern';
import { nanoid, prefixedId } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../../constants/date-formats';
import type { TEnvelopeExpirationPeriod } from '../../constants/envelope-expiration';
import type { SupportedLanguageCodes } from '../../constants/i18n';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { ZDefaultRecipientsSchema } from '../../types/default-recipients';
@@ -32,16 +32,8 @@ import type {
TRadioFieldMeta,
TTextFieldMeta,
} from '../../types/field-meta';
import {
ZCheckboxFieldMeta,
ZDropdownFieldMeta,
ZFieldMetaSchema,
ZRadioFieldMeta,
} from '../../types/field-meta';
import {
ZWebhookDocumentSchema,
mapEnvelopeToWebhookDocumentPayload,
} from '../../types/webhook-payload';
import { ZCheckboxFieldMeta, ZDropdownFieldMeta, ZFieldMetaSchema, ZRadioFieldMeta } from '../../types/field-meta';
import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../types/webhook-payload';
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
import { getFileServerSide } from '../../universal/upload/get-file.server';
import { putNormalizedPdfFileServerSide } from '../../universal/upload/put-file.server';
@@ -60,11 +52,9 @@ import { incrementDocumentId } from '../envelope/increment-id';
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
import { getTeamSettings } from '../team/get-team-settings';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { getOrganisationTemplateWhereInput } from './get-organisation-template-by-id';
type FinalRecipient = Pick<
Recipient,
'name' | 'email' | 'role' | 'authOptions' | 'signingOrder' | 'token'
> & {
type FinalRecipient = Pick<Recipient, 'name' | 'email' | 'role' | 'authOptions' | 'signingOrder' | 'token'> & {
templateRecipientId: number;
fields: Field[];
};
@@ -119,6 +109,7 @@ export type CreateDocumentFromTemplateOptions = {
typedSignatureEnabled?: boolean;
uploadSignatureEnabled?: boolean;
drawSignatureEnabled?: boolean;
envelopeExpirationPeriod?: TEnvelopeExpirationPeriod | null;
};
formValues?: TDocumentFormValues;
@@ -310,29 +301,43 @@ export const createDocumentFromTemplate = async ({
attachments,
formValues,
}: CreateDocumentFromTemplateOptions) => {
const { envelopeWhereInput } = await getEnvelopeWhereInput({
const templateInclude = {
recipients: {
include: {
fields: true,
},
},
envelopeItems: {
include: {
documentData: true,
},
},
documentMeta: true,
} as const;
const { envelopeWhereInput, team: callerTeam } = await getEnvelopeWhereInput({
id,
type: EnvelopeType.TEMPLATE,
userId,
teamId,
});
const template = await prisma.envelope.findUnique({
where: envelopeWhereInput,
include: {
recipients: {
include: {
fields: true,
},
},
envelopeItems: {
include: {
documentData: true,
},
},
documentMeta: true,
},
});
const [teamTemplate, organisationTemplate] = await Promise.all([
prisma.envelope.findFirst({
where: envelopeWhereInput,
include: templateInclude,
}),
prisma.envelope.findFirst({
where: getOrganisationTemplateWhereInput({
id,
organisationId: callerTeam.organisationId,
teamRole: callerTeam.currentTeamRole,
}),
include: templateInclude,
}),
]);
const template = teamTemplate ?? organisationTemplate;
if (!template) {
throw new AppError(AppErrorCode.NOT_FOUND, {
@@ -372,9 +377,7 @@ export const createDocumentFromTemplate = async ({
// Check that all the passed in recipient IDs can be associated with a template recipient.
recipients.forEach((recipient) => {
const foundRecipient = template.recipients.find(
(templateRecipient) => templateRecipient.id === recipient.id,
);
const foundRecipient = template.recipients.find((templateRecipient) => templateRecipient.id === recipient.id);
if (!foundRecipient) {
throw new AppError(AppErrorCode.INVALID_BODY, {
@@ -513,18 +516,15 @@ export const createDocumentFromTemplate = async ({
emailSettings: override?.emailSettings || template.documentMeta?.emailSettings,
signingOrder: override?.signingOrder || template.documentMeta?.signingOrder,
language: override?.language || template.documentMeta?.language || settings.documentLanguage,
typedSignatureEnabled:
override?.typedSignatureEnabled ?? template.documentMeta?.typedSignatureEnabled,
uploadSignatureEnabled:
override?.uploadSignatureEnabled ?? template.documentMeta?.uploadSignatureEnabled,
drawSignatureEnabled:
override?.drawSignatureEnabled ?? template.documentMeta?.drawSignatureEnabled,
allowDictateNextSigner:
override?.allowDictateNextSigner ?? template.documentMeta?.allowDictateNextSigner,
typedSignatureEnabled: override?.typedSignatureEnabled ?? template.documentMeta?.typedSignatureEnabled,
uploadSignatureEnabled: override?.uploadSignatureEnabled ?? template.documentMeta?.uploadSignatureEnabled,
drawSignatureEnabled: override?.drawSignatureEnabled ?? template.documentMeta?.drawSignatureEnabled,
allowDictateNextSigner: override?.allowDictateNextSigner ?? template.documentMeta?.allowDictateNextSigner,
envelopeExpirationPeriod: override?.envelopeExpirationPeriod ?? template.documentMeta?.envelopeExpirationPeriod,
}),
});
return await prisma.$transaction(async (tx) => {
const { envelope, createdEnvelope } = await prisma.$transaction(async (tx) => {
const envelope = await tx.envelope.create({
data: {
id: prefixedId('envelope'),
@@ -537,7 +537,7 @@ export const createDocumentFromTemplate = async ({
templateId: legacyTemplateId, // The template this envelope was created from.
userId,
folderId,
teamId: template.teamId,
teamId,
title: finalEnvelopeTitle,
envelopeItems: {
createMany: {
@@ -565,12 +565,8 @@ export const createDocumentFromTemplate = async ({
accessAuth: authOptions.accessAuth,
actionAuth: authOptions.actionAuth,
}),
sendStatus:
recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT,
signingStatus:
recipient.role === RecipientRole.CC
? SigningStatus.SIGNED
: SigningStatus.NOT_SIGNED,
sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT,
signingStatus: recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
signingOrder: recipient.signingOrder,
token: recipient.token,
};
@@ -595,9 +591,7 @@ export const createDocumentFromTemplate = async ({
let fieldsToCreate: Omit<Field, 'id' | 'secondaryId'>[] = [];
// Get all template field IDs first so we can validate later
const allTemplateFieldIds = finalRecipients.flatMap((recipient) =>
recipient.fields.map((field) => field.id),
);
const allTemplateFieldIds = finalRecipients.flatMap((recipient) => recipient.fields.map((field) => field.id));
if (prefillFields?.length) {
// Validate that all prefill field IDs exist in the template
@@ -757,13 +751,25 @@ export const createDocumentFromTemplate = async ({
throw new Error('Document not found');
}
await triggerWebhook({
return { envelope, createdEnvelope };
});
// Trigger webhook outside the transaction to avoid holding the connection
// open during network I/O.
await Promise.allSettled([
triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_CREATED,
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(createdEnvelope)),
userId,
teamId,
});
}),
triggerWebhook({
event: WebhookTriggerEvents.TEMPLATE_USED,
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(createdEnvelope)),
userId,
teamId,
}),
]);
return envelope;
});
return envelope;
};
@@ -1,11 +1,10 @@
import { EnvelopeType, type Recipient } from '@prisma/client';
import { nanoid } from 'nanoid';
import {
DIRECT_TEMPLATE_RECIPIENT_EMAIL,
DIRECT_TEMPLATE_RECIPIENT_NAME,
} from '@documenso/lib/constants/direct-templates';
import { prisma } from '@documenso/prisma';
import { EnvelopeType, type Recipient } from '@prisma/client';
import { nanoid } from 'nanoid';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { type EnvelopeIdOptions, mapSecondaryIdToTemplateId } from '../../utils/envelope';
@@ -47,18 +46,13 @@ export const createTemplateDirectLink = async ({
throw new AppError(AppErrorCode.ALREADY_EXISTS, { message: 'Direct template already exists' });
}
if (
directRecipientId &&
!envelope.recipients.find((recipient) => recipient.id === directRecipientId)
) {
if (directRecipientId && !envelope.recipients.find((recipient) => recipient.id === directRecipientId)) {
throw new AppError(AppErrorCode.NOT_FOUND, { message: 'Recipient not found' });
}
if (
!directRecipientId &&
envelope.recipients.find(
(recipient) => recipient.email.toLowerCase() === DIRECT_TEMPLATE_RECIPIENT_EMAIL,
)
envelope.recipients.find((recipient) => recipient.email.toLowerCase() === DIRECT_TEMPLATE_RECIPIENT_EMAIL)
) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Cannot generate placeholder direct recipient',
@@ -1,7 +1,6 @@
import { EnvelopeType } from '@prisma/client';
import { generateAvaliableRecipientPlaceholder } from '@documenso/lib/utils/templates';
import { prisma } from '@documenso/prisma';
import { EnvelopeType } from '@prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
@@ -1,9 +1,10 @@
import { EnvelopeType } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { EnvelopeType, WebhookTriggerEvents } from '@prisma/client';
import { type EnvelopeIdOptions } from '../../utils/envelope';
import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../types/webhook-payload';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export type DeleteTemplateOptions = {
id: EnvelopeIdOptions;
@@ -19,7 +20,21 @@ export const deleteTemplate = async ({ id, userId, teamId }: DeleteTemplateOptio
teamId,
});
return await prisma.envelope.delete({
const templateToDelete = await prisma.envelope.findUniqueOrThrow({
where: envelopeWhereInput,
include: { documentMeta: true, recipients: true },
});
const deletedTemplate = await prisma.envelope.delete({
where: envelopeWhereInput,
});
await triggerWebhook({
event: WebhookTriggerEvents.TEMPLATE_DELETED,
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(templateToDelete)),
userId,
teamId,
});
return deletedTemplate;
};
@@ -0,0 +1,83 @@
import { prisma } from '@documenso/prisma';
import { EnvelopeType, type Prisma, TemplateType } from '@prisma/client';
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
import type { FindResultResponse } from '../../types/search-params';
import { getMemberRoles } from '../team/get-member-roles';
import { getTeamById } from '../team/get-team';
export type FindOrganisationTemplatesOptions = {
userId: number;
teamId: number;
page?: number;
perPage?: number;
};
export const findOrganisationTemplates = async ({
userId,
teamId,
page = 1,
perPage = 10,
}: FindOrganisationTemplatesOptions) => {
const [team, { teamRole }] = await Promise.all([
getTeamById({ teamId, userId }),
getMemberRoles({
teamId,
reference: {
type: 'User',
id: userId,
},
}),
]);
const where: Prisma.EnvelopeWhereInput = {
type: EnvelopeType.TEMPLATE,
templateType: TemplateType.ORGANISATION,
visibility: {
in: TEAM_DOCUMENT_VISIBILITY_MAP[teamRole],
},
team: {
organisationId: team.organisationId,
},
};
const templateInclude = {
team: {
select: {
id: true,
url: true,
name: true,
},
},
fields: true,
recipients: true,
documentMeta: true,
directLink: {
select: {
token: true,
enabled: true,
},
},
} as const;
const [data, count] = await Promise.all([
prisma.envelope.findMany({
where,
include: templateInclude,
skip: Math.max(page - 1, 0) * perPage,
take: perPage,
orderBy: {
createdAt: 'desc',
},
}),
prisma.envelope.count({ where }),
]);
return {
data,
count,
currentPage: Math.max(page, 1),
perPage,
totalPages: Math.ceil(count / perPage),
} satisfies FindResultResponse<typeof data>;
};
@@ -1,10 +1,9 @@
import { prisma } from '@documenso/prisma';
import type { TemplateType } from '@prisma/client';
import { EnvelopeType, type Prisma } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
import { type FindResultResponse } from '../../types/search-params';
import type { FindResultResponse } from '../../types/search-params';
import { getMemberRoles } from '../team/get-member-roles';
export type FindTemplatesOptions = {
@@ -24,8 +23,6 @@ export const findTemplates = async ({
perPage = 10,
folderId,
}: FindTemplatesOptions) => {
const whereFilter: Prisma.EnvelopeWhereInput[] = [];
const { teamRole } = await getMemberRoles({
teamId,
reference: {
@@ -34,63 +31,55 @@ export const findTemplates = async ({
},
});
whereFilter.push(
{ teamId },
{
OR: [
{
visibility: {
in: TEAM_DOCUMENT_VISIBILITY_MAP[teamRole],
const where: Prisma.EnvelopeWhereInput = {
type: EnvelopeType.TEMPLATE,
templateType: type,
AND: [
{ teamId },
{
OR: [
{
visibility: {
in: TEAM_DOCUMENT_VISIBILITY_MAP[teamRole],
},
},
},
{ userId, teamId },
],
},
);
{ userId, teamId },
],
},
folderId ? { folderId } : { folderId: null },
],
};
if (folderId) {
whereFilter.push({ folderId });
} else {
whereFilter.push({ folderId: null });
}
const templateInclude = {
team: {
select: {
id: true,
url: true,
name: true,
},
},
fields: true,
recipients: true,
documentMeta: true,
directLink: {
select: {
token: true,
enabled: true,
},
},
} as const;
const [data, count] = await Promise.all([
prisma.envelope.findMany({
where: {
type: EnvelopeType.TEMPLATE,
templateType: type,
AND: whereFilter,
},
include: {
team: {
select: {
id: true,
url: true,
},
},
fields: true,
recipients: true,
documentMeta: true,
directLink: {
select: {
token: true,
enabled: true,
},
},
},
where,
include: templateInclude,
skip: Math.max(page - 1, 0) * perPage,
take: perPage,
orderBy: {
createdAt: 'desc',
},
}),
prisma.envelope.count({
where: {
type: EnvelopeType.TEMPLATE,
templateType: type,
AND: whereFilter,
},
}),
prisma.envelope.count({ where }),
]);
return {
@@ -0,0 +1,131 @@
import { prisma } from '@documenso/prisma';
import type { Prisma, TeamMemberRole } from '@prisma/client';
import { EnvelopeType, TemplateType } from '@prisma/client';
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
import { getMemberRoles } from '../team/get-member-roles';
import { getTeamById } from '../team/get-team';
export type GetOrganisationTemplateByIdOptions = {
id: EnvelopeIdOptions;
userId: number;
teamId: number;
};
/**
* Get an organisation template by ID.
*
* This validates that the caller's team belongs to the same organisation as the template's team,
* that the template is of type ORGANISATION, and that the template's visibility is permitted
* for the caller's role on their own team.
*/
export const getOrganisationTemplateById = async ({ id, userId, teamId }: GetOrganisationTemplateByIdOptions) => {
const [callerTeam, { teamRole }] = await Promise.all([
getTeamById({ teamId, userId }),
getMemberRoles({
teamId,
reference: {
type: 'User',
id: userId,
},
}),
]);
const envelope = await prisma.envelope.findFirst({
where: {
...unsafeBuildEnvelopeIdQuery(id, EnvelopeType.TEMPLATE),
templateType: TemplateType.ORGANISATION,
visibility: {
in: TEAM_DOCUMENT_VISIBILITY_MAP[teamRole],
},
team: {
organisationId: callerTeam.organisationId,
},
},
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,
},
},
directLink: {
select: {
directTemplateRecipientId: true,
enabled: true,
id: true,
token: true,
},
},
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Organisation template not found',
});
}
return {
...envelope,
user: {
id: envelope.user.id,
name: envelope.user.name || '',
email: envelope.user.email,
},
};
};
/**
* Build a where input for querying an organisation template.
*
* Matches a TEMPLATE envelope with templateType ORGANISATION belonging to any team
* within the provided organisation, respecting the caller's team role visibility.
*/
export const getOrganisationTemplateWhereInput = ({
id,
organisationId,
teamRole,
}: {
id: EnvelopeIdOptions;
organisationId: string;
teamRole: TeamMemberRole;
}): Prisma.EnvelopeWhereInput => {
return {
...unsafeBuildEnvelopeIdQuery(id, EnvelopeType.TEMPLATE),
type: EnvelopeType.TEMPLATE,
templateType: TemplateType.ORGANISATION,
visibility: {
in: TEAM_DOCUMENT_VISIBILITY_MAP[teamRole],
},
team: {
organisationId,
},
};
};
@@ -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 { mapSecondaryIdToTemplateId } from '../../utils/envelope';
@@ -9,9 +8,7 @@ export interface GetTemplateByDirectLinkTokenOptions {
token: string;
}
export const getTemplateByDirectLinkToken = async ({
token,
}: GetTemplateByDirectLinkTokenOptions) => {
export const getTemplateByDirectLinkToken = async ({ token }: GetTemplateByDirectLinkTokenOptions) => {
const envelope = await prisma.envelope.findFirst({
where: {
type: EnvelopeType.TEMPLATE,
@@ -90,6 +87,7 @@ export const getTemplateByDirectLinkToken = async ({
envelopeItems: envelope.envelopeItems.map((item) => ({
id: item.id,
envelopeId: item.envelopeId,
documentDataId: item.documentDataId,
})),
};
};
@@ -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 { EnvelopeIdOptions } from '../../utils/envelope';
@@ -0,0 +1,129 @@
import { formatTemplatesPath, getHighestTeamRoleInGroup } from '@documenso/lib/utils/teams';
import { prisma } from '@documenso/prisma';
import type { Prisma } from '@prisma/client';
import { DocumentVisibility, EnvelopeType, TeamMemberRole } from '@prisma/client';
import { match } from 'ts-pattern';
import { mapSecondaryIdToTemplateId } from '../../utils/envelope';
import { getUserTeamGroups } from '../team/get-user-team-groups';
export type SearchTemplatesWithKeywordOptions = {
query: string;
userId: number;
limit?: number;
};
export const searchTemplatesWithKeyword = async ({ query, userId, limit = 20 }: SearchTemplatesWithKeywordOptions) => {
if (!query.trim()) {
return [];
}
const [user, teamGroupsByTeamId] = await Promise.all([
prisma.user.findFirstOrThrow({
where: {
id: userId,
},
}),
getUserTeamGroups({ userId }),
]);
const teamIds = [...teamGroupsByTeamId.keys()];
const titleOrRecipientMatch: Prisma.EnvelopeWhereInput = {
OR: [
{ title: { contains: query, mode: 'insensitive' } },
{
recipients: {
some: { email: { contains: query, mode: 'insensitive' } },
},
},
],
};
const filters: Prisma.EnvelopeWhereInput[] = [
// Templates owned by the user matching title or recipient email.
{
userId,
deletedAt: null,
...titleOrRecipientMatch,
},
];
// Team templates the user has access to.
if (teamIds.length > 0) {
filters.push({
teamId: { in: teamIds },
deletedAt: null,
...titleOrRecipientMatch,
});
}
const envelopes = await prisma.envelope.findMany({
where: {
type: EnvelopeType.TEMPLATE,
OR: filters,
},
select: {
id: true,
userId: true,
teamId: true,
title: true,
secondaryId: true,
visibility: true,
recipients: {
select: {
email: true,
},
},
team: {
select: {
id: true,
url: true,
},
},
},
distinct: ['id'],
orderBy: {
createdAt: 'desc',
},
// Over-fetch to compensate for post-query visibility filtering on team templates.
take: limit * 3,
});
const results = envelopes
.filter((envelope) => {
if (!envelope.teamId || envelope.userId === user.id) {
return true;
}
const teamGroups = teamGroupsByTeamId.get(envelope.teamId) ?? [];
const teamMemberRole = getHighestTeamRoleInGroup(teamGroups);
if (!teamMemberRole) {
return false;
}
return match([envelope.visibility, teamMemberRole])
.with([DocumentVisibility.EVERYONE, TeamMemberRole.ADMIN], () => true)
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MANAGER], () => true)
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MEMBER], () => true)
.with([DocumentVisibility.MANAGER_AND_ABOVE, TeamMemberRole.ADMIN], () => true)
.with([DocumentVisibility.MANAGER_AND_ABOVE, TeamMemberRole.MANAGER], () => true)
.with([DocumentVisibility.ADMIN, TeamMemberRole.ADMIN], () => true)
.otherwise(() => false);
})
.slice(0, limit)
.map((envelope) => {
const legacyTemplateId = mapSecondaryIdToTemplateId(envelope.secondaryId);
const path = `${formatTemplatesPath(envelope.team.url)}/${legacyTemplateId}`;
return {
title: envelope.title,
path,
value: [envelope.id, envelope.title, ...envelope.recipients.map((r) => r.email)].join(' '),
};
});
return results;
};
@@ -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 { mapSecondaryIdToTemplateId } from '../../utils/envelope';