feat: add template enhancements (#1154)

## Description

General enhancements for templates.

## Changes Made

Added the following changes to the template flow:
- Allow adding document meta settings
- Allow adding email settings
- Allow adding document access & action authentication
- Allow adding recipient action authentication
- Save the state between template steps similar to how it works for
documents

Other changes:
- Extract common fields between document and template flows
- Remove the title field from "Use template" since we now have it as
part of the template flow
- Add new API endpoint for generating templates

## Testing Performed

Added E2E tests for templates and creating documents from templates
This commit is contained in:
David Nguyen
2024-05-10 19:45:19 +07:00
committed by GitHub
parent 8f9c07aa8e
commit 2f86bb523b
38 changed files with 2103 additions and 510 deletions

View File

@@ -0,0 +1,12 @@
import { z } from 'zod';
import { URL_REGEX } from '../constants/url-regex';
/**
* Note this allows empty strings.
*/
export const ZUrlSchema = z
.string()
.refine((value) => value === undefined || value === '' || URL_REGEX.test(value), {
message: 'Please enter a valid URL',
});

View File

@@ -1,22 +1,19 @@
import { prisma } from '@documenso/prisma';
import type { FieldType } from '@documenso/prisma/client';
export type Field = {
id?: number | null;
type: FieldType;
signerEmail: string;
signerId?: number;
pageNumber: number;
pageX: number;
pageY: number;
pageWidth: number;
pageHeight: number;
};
export type SetFieldsForTemplateOptions = {
userId: number;
templateId: number;
fields: Field[];
fields: {
id?: number | null;
type: FieldType;
signerEmail: string;
pageNumber: number;
pageX: number;
pageY: number;
pageWidth: number;
pageHeight: number;
}[];
};
export const setFieldsForTemplate = async ({
@@ -58,11 +55,7 @@ export const setFieldsForTemplate = async ({
});
const removedFields = existingFields.filter(
(existingField) =>
!fields.find(
(field) =>
field.id === existingField.id || field.signerEmail === existingField.Recipient?.email,
),
(existingField) => !fields.find((field) => field.id === existingField.id),
);
const linkedFields = fields.map((field) => {
@@ -127,5 +120,13 @@ export const setFieldsForTemplate = async ({
});
}
return persistedFields;
// Filter out fields that have been removed or have been updated.
const filteredFields = existingFields.filter((field) => {
const isRemoved = removedFields.find((removedField) => removedField.id === field.id);
const isUpdated = persistedFields.find((persistedField) => persistedField.id === field.id);
return !isRemoved && !isUpdated;
});
return [...filteredFields, ...persistedFields];
};

View File

@@ -1,21 +1,32 @@
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { prisma } from '@documenso/prisma';
import type { RecipientRole } from '@documenso/prisma/client';
import type { Recipient } from '@documenso/prisma/client';
import { RecipientRole } from '@documenso/prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import {
type TRecipientActionAuthTypes,
ZRecipientAuthOptionsSchema,
} from '../../types/document-auth';
import { nanoid } from '../../universal/id';
import { createRecipientAuthOptions } from '../../utils/document-auth';
export type SetRecipientsForTemplateOptions = {
userId: number;
teamId?: number;
templateId: number;
recipients: {
id?: number;
email: string;
name: string;
role: RecipientRole;
actionAuth?: TRecipientActionAuthTypes | null;
}[];
};
export const setRecipientsForTemplate = async ({
userId,
teamId,
templateId,
recipients,
}: SetRecipientsForTemplateOptions) => {
@@ -43,6 +54,23 @@ export const setRecipientsForTemplate = async ({
throw new Error('Template not found');
}
const recipientsHaveActionAuth = recipients.some((recipient) => recipient.actionAuth);
// Check if user has permission to set the global action auth.
if (recipientsHaveActionAuth) {
const isDocumentEnterprise = await isUserEnterprise({
userId,
teamId,
});
if (!isDocumentEnterprise) {
throw new AppError(
AppErrorCode.UNAUTHORIZED,
'You do not have permission to set the action auth',
);
}
}
const normalizedRecipients = recipients.map((recipient) => ({
...recipient,
email: recipient.email.toLowerCase(),
@@ -74,31 +102,59 @@ export const setRecipientsForTemplate = async ({
};
});
const persistedRecipients = await prisma.$transaction(
// Disabling as wrapping promises here causes type issues
// eslint-disable-next-line @typescript-eslint/promise-function-async
linkedRecipients.map((recipient) =>
prisma.recipient.upsert({
where: {
id: recipient._persisted?.id ?? -1,
templateId,
},
update: {
name: recipient.name,
email: recipient.email,
role: recipient.role,
templateId,
},
create: {
name: recipient.name,
email: recipient.email,
role: recipient.role,
token: nanoid(),
templateId,
},
const persistedRecipients = await prisma.$transaction(async (tx) => {
return await Promise.all(
linkedRecipients.map(async (recipient) => {
let authOptions = ZRecipientAuthOptionsSchema.parse(recipient._persisted?.authOptions);
if (recipient.actionAuth !== undefined) {
authOptions = createRecipientAuthOptions({
accessAuth: authOptions.accessAuth,
actionAuth: recipient.actionAuth,
});
}
const upsertedRecipient = await tx.recipient.upsert({
where: {
id: recipient._persisted?.id ?? -1,
templateId,
},
update: {
name: recipient.name,
email: recipient.email,
role: recipient.role,
templateId,
authOptions,
},
create: {
name: recipient.name,
email: recipient.email,
role: recipient.role,
token: nanoid(),
templateId,
authOptions,
},
});
const recipientId = upsertedRecipient.id;
// Clear all fields if the recipient role is changed to a type that cannot have fields.
if (
recipient._persisted &&
recipient._persisted.role !== recipient.role &&
(recipient.role === RecipientRole.CC || recipient.role === RecipientRole.VIEWER)
) {
await tx.field.deleteMany({
where: {
recipientId,
},
});
}
return upsertedRecipient;
}),
),
);
);
});
if (removedRecipients.length > 0) {
await prisma.recipient.deleteMany({
@@ -110,5 +166,17 @@ export const setRecipientsForTemplate = async ({
});
}
return persistedRecipients;
// Filter out recipients that have been removed or have been updated.
const filteredRecipients: Recipient[] = existingRecipients.filter((recipient) => {
const isRemoved = removedRecipients.find(
(removedRecipient) => removedRecipient.id === recipient.id,
);
const isUpdated = persistedRecipients.find(
(persistedRecipient) => persistedRecipient.id === recipient.id,
);
return !isRemoved && !isUpdated;
});
return [...filteredRecipients, ...persistedRecipients];
};

View File

@@ -5,15 +5,25 @@ import { type Recipient, WebhookTriggerEvents } from '@documenso/prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { ZRecipientAuthOptionsSchema } from '../../types/document-auth';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import {
createDocumentAuthOptions,
createRecipientAuthOptions,
extractDocumentAuthMethods,
} from '../../utils/document-auth';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
type FinalRecipient = Pick<Recipient, 'name' | 'email' | 'role'> & {
type FinalRecipient = Pick<Recipient, 'name' | 'email' | 'role' | 'authOptions'> & {
templateRecipientId: number;
fields: Field[];
};
export type CreateDocumentFromTemplateResponse = Awaited<
ReturnType<typeof createDocumentFromTemplate>
>;
export type CreateDocumentFromTemplateOptions = {
templateId: number;
userId: number;
@@ -23,6 +33,19 @@ export type CreateDocumentFromTemplateOptions = {
name?: string;
email: string;
}[];
/**
* Values that will override the predefined values in the template.
*/
override?: {
title?: string;
subject?: string;
message?: string;
timezone?: string;
password?: string;
dateFormat?: string;
redirectUrl?: string;
};
requestMetadata?: RequestMetadata;
};
@@ -31,6 +54,7 @@ export const createDocumentFromTemplate = async ({
userId,
teamId,
recipients,
override,
requestMetadata,
}: CreateDocumentFromTemplateOptions) => {
const user = await prisma.user.findFirstOrThrow({
@@ -65,6 +89,7 @@ export const createDocumentFromTemplate = async ({
},
},
templateDocumentData: true,
templateMeta: true,
},
});
@@ -72,26 +97,34 @@ export const createDocumentFromTemplate = async ({
throw new AppError(AppErrorCode.NOT_FOUND, 'Template not found');
}
if (recipients.length !== template.Recipient.length) {
throw new AppError(AppErrorCode.INVALID_BODY, 'Invalid number of recipients.');
}
const finalRecipients: FinalRecipient[] = template.Recipient.map((templateRecipient) => {
const foundRecipient = recipients.find((recipient) => recipient.id === templateRecipient.id);
// Check that all the passed in recipient IDs can be associated with a template recipient.
recipients.forEach((recipient) => {
const foundRecipient = template.Recipient.find(
(templateRecipient) => templateRecipient.id === recipient.id,
);
if (!foundRecipient) {
throw new AppError(
AppErrorCode.INVALID_BODY,
`Missing template recipient with ID ${templateRecipient.id}`,
`Recipient with ID ${recipient.id} not found in the template.`,
);
}
});
const { documentAuthOption: templateAuthOptions } = extractDocumentAuthMethods({
documentAuth: template.authOptions,
});
const finalRecipients: FinalRecipient[] = template.Recipient.map((templateRecipient) => {
const foundRecipient = recipients.find((recipient) => recipient.id === templateRecipient.id);
return {
templateRecipientId: templateRecipient.id,
fields: templateRecipient.Field,
name: foundRecipient.name ?? '',
email: foundRecipient.email,
name: foundRecipient ? foundRecipient.name ?? '' : templateRecipient.name,
email: foundRecipient ? foundRecipient.email : templateRecipient.email,
role: templateRecipient.role,
authOptions: templateRecipient.authOptions,
};
});
@@ -108,16 +141,38 @@ export const createDocumentFromTemplate = async ({
data: {
userId,
teamId: template.teamId,
title: template.title,
title: override?.title || template.title,
documentDataId: documentData.id,
authOptions: createDocumentAuthOptions({
globalAccessAuth: templateAuthOptions.globalAccessAuth,
globalActionAuth: templateAuthOptions.globalActionAuth,
}),
documentMeta: {
create: {
subject: override?.subject || template.templateMeta?.subject,
message: override?.message || template.templateMeta?.message,
timezone: override?.timezone || template.templateMeta?.timezone,
password: override?.password || template.templateMeta?.password,
dateFormat: override?.dateFormat || template.templateMeta?.dateFormat,
redirectUrl: override?.redirectUrl || template.templateMeta?.redirectUrl,
},
},
Recipient: {
createMany: {
data: finalRecipients.map((recipient) => ({
email: recipient.email,
name: recipient.name,
role: recipient.role,
token: nanoid(),
})),
data: finalRecipients.map((recipient) => {
const authOptions = ZRecipientAuthOptionsSchema.parse(recipient?.authOptions);
return {
email: recipient.email,
name: recipient.name,
role: recipient.role,
authOptions: createRecipientAuthOptions({
accessAuth: authOptions.accessAuth,
actionAuth: authOptions.actionAuth,
}),
token: nanoid(),
};
}),
},
},
},

View File

@@ -0,0 +1,38 @@
import { prisma } from '@documenso/prisma';
import type { TemplateWithDetails } from '@documenso/prisma/types/template';
export type GetTemplateWithDetailsByIdOptions = {
id: number;
userId: number;
};
export const getTemplateWithDetailsById = async ({
id,
userId,
}: GetTemplateWithDetailsByIdOptions): Promise<TemplateWithDetails> => {
return await prisma.template.findFirstOrThrow({
where: {
id,
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
},
},
},
},
],
},
include: {
templateDocumentData: true,
templateMeta: true,
Recipient: true,
Field: true,
},
});
};

View File

@@ -0,0 +1,139 @@
'use server';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { prisma } from '@documenso/prisma';
import type { TemplateMeta } from '@documenso/prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth';
import { createDocumentAuthOptions, extractDocumentAuthMethods } from '../../utils/document-auth';
export type UpdateTemplateSettingsOptions = {
userId: number;
teamId?: number;
templateId: number;
data: {
title?: string;
globalAccessAuth?: TDocumentAccessAuthTypes | null;
globalActionAuth?: TDocumentActionAuthTypes | null;
};
meta?: Partial<Omit<TemplateMeta, 'id' | 'templateId'>>;
requestMetadata?: RequestMetadata;
};
export const updateTemplateSettings = async ({
userId,
teamId,
templateId,
meta,
data,
}: UpdateTemplateSettingsOptions) => {
if (!data.title && !data.globalAccessAuth && !data.globalActionAuth) {
throw new AppError(AppErrorCode.INVALID_BODY, 'Missing data to update');
}
const template = await prisma.template.findFirstOrThrow({
where: {
id: templateId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
include: {
templateMeta: true,
},
});
const { documentAuthOption } = extractDocumentAuthMethods({
documentAuth: template.authOptions,
});
const { templateMeta } = template;
const isDateSame = (templateMeta?.dateFormat || null) === (meta?.dateFormat || null);
const isMessageSame = (templateMeta?.message || null) === (meta?.message || null);
const isPasswordSame = (templateMeta?.password || null) === (meta?.password || null);
const isSubjectSame = (templateMeta?.subject || null) === (meta?.subject || null);
const isRedirectUrlSame = (templateMeta?.redirectUrl || null) === (meta?.redirectUrl || null);
const isTimezoneSame = (templateMeta?.timezone || null) === (meta?.timezone || null);
// Early return to avoid unnecessary updates.
if (
template.title === data.title &&
data.globalAccessAuth === documentAuthOption.globalAccessAuth &&
data.globalActionAuth === documentAuthOption.globalActionAuth &&
isDateSame &&
isMessageSame &&
isPasswordSame &&
isSubjectSame &&
isRedirectUrlSame &&
isTimezoneSame
) {
return template;
}
const documentGlobalAccessAuth = documentAuthOption?.globalAccessAuth ?? null;
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;
// Check if user has permission to set the global action auth.
if (newGlobalActionAuth) {
const isDocumentEnterprise = await isUserEnterprise({
userId,
teamId,
});
if (!isDocumentEnterprise) {
throw new AppError(
AppErrorCode.UNAUTHORIZED,
'You do not have permission to set the action auth',
);
}
}
const authOptions = createDocumentAuthOptions({
globalAccessAuth: newGlobalAccessAuth,
globalActionAuth: newGlobalActionAuth,
});
return await prisma.template.update({
where: {
id: templateId,
},
data: {
title: data.title,
authOptions,
templateMeta: {
upsert: {
where: {
templateId,
},
create: {
...meta,
},
update: {
...meta,
},
},
},
},
});
};