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:
@@ -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(),
|
||||
};
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
};
|
||||
139
packages/lib/server-only/template/update-template-settings.ts
Normal file
139
packages/lib/server-only/template/update-template-settings.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user