feat: add direct templates links (#1165)

## Description

Direct templates links is a feature that provides template owners the
ability to allow users to create documents based of their templates.

## General outline

This works by allowing the template owner to configure a "direct
recipient" in the template.

When a user opens the direct link to the template, it will create a flow
where they sign the fields configured by the template owner for the
direct recipient. After these fields are signed the following will
occur:

- A document will be created where the owner is the template owner
- The direct recipient fields will be signed
- The document will be sent to any other recipients configured in the
template
- If there are none the document will be immediately completed

## Notes

There's a custom prisma migration to migrate all documents to have
'DOCUMENT' as the source, then sets the column to required.

---------

Co-authored-by: Lucas Smith <me@lucasjamessmith.me>
This commit is contained in:
David Nguyen
2024-06-02 15:49:09 +10:00
committed by GitHub
parent c346a3fd6a
commit d11a68fc4c
71 changed files with 3636 additions and 283 deletions

View File

@@ -1,2 +1,28 @@
export const TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX = /recipient\.\d+@documenso\.com/i;
export const TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX = /Recipient \d+/i;
export const DIRECT_TEMPLATE_DOCUMENTATION = [
{
title: 'Enable Direct Link Signing',
description:
'Once enabled, you can select any active recipient to be a direct link signing recipient, or create a new one. This recipient type cannot be edited or deleted.',
},
{
title: 'Configure Direct Recipient',
description:
'Update the role and add fields as required for the direct recipient. The individual who uses the direct link will sign the document as the direct recipient.',
},
{
title: 'Share the Link',
description:
'Once your template is set up, share the link anywhere you want. The person who opens the link will be able to enter their information in the direct link recipient field and complete any other fields assigned to them.',
},
{
title: 'Document Creation',
description:
'After submission, a document will be automatically generated and added to your documents page. You will also receive a notification via email.',
},
];
export const DIRECT_TEMPLATE_RECIPIENT_EMAIL = 'direct.link@documenso.com';
export const DIRECT_TEMPLATE_RECIPIENT_NAME = 'Direct link recipient';

View File

@@ -12,6 +12,7 @@ export enum AppErrorCode {
'EXPIRED_CODE' = 'ExpiredCode',
'INVALID_BODY' = 'InvalidBody',
'INVALID_REQUEST' = 'InvalidRequest',
'LIMIT_EXCEEDED' = 'LimitExceeded',
'NOT_FOUND' = 'NotFound',
'NOT_SETUP' = 'NotSetup',
'UNAUTHORIZED' = 'Unauthorized',

View File

@@ -5,7 +5,7 @@ import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-log
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 { WebhookTriggerEvents } from '@documenso/prisma/client';
import { DocumentSource, WebhookTriggerEvents } from '@documenso/prisma/client';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
@@ -54,6 +54,7 @@ export const createDocument = async ({
userId,
teamId,
formValues,
source: DocumentSource.DOCUMENT,
},
});
@@ -65,6 +66,9 @@ export const createDocument = async ({
requestMetadata,
data: {
title,
source: {
type: DocumentSource.DOCUMENT,
},
},
}),
});

View File

@@ -1,5 +1,5 @@
import { prisma } from '@documenso/prisma';
import type { Prisma } from '@documenso/prisma/client';
import { DocumentSource, type Prisma } from '@documenso/prisma/client';
import { getDocumentWhereInput } from './get-document-by-id';
@@ -64,6 +64,7 @@ export const duplicateDocumentById = async ({
...document.documentMeta,
},
},
source: DocumentSource.DOCUMENT,
},
};

View File

@@ -99,7 +99,7 @@ export const getDocumentAndSenderByToken = async ({
if (requireAccessAuth) {
documentAccessValid = await isRecipientAuthorized({
type: 'ACCESS',
document: result,
documentAuthOptions: result.authOptions,
recipient,
userId,
authOptions: accessAuth,
@@ -159,7 +159,7 @@ export const getDocumentAndRecipientByToken = async ({
if (requireAccessAuth) {
documentAccessValid = await isRecipientAuthorized({
type: 'ACCESS',
document: result,
documentAuthOptions: result.authOptions,
recipient,
userId,
authOptions: accessAuth,

View File

@@ -14,8 +14,8 @@ import { extractDocumentAuthMethods } from '../../utils/document-auth';
type IsRecipientAuthorizedOptions = {
type: 'ACCESS' | 'ACTION';
document: Document;
recipient: Recipient;
documentAuthOptions: Document['authOptions'];
recipient: Pick<Recipient, 'authOptions' | 'email'>;
/**
* The ID of the user who initiated the request.
@@ -50,13 +50,13 @@ const getUserByEmail = async (email: string) => {
*/
export const isRecipientAuthorized = async ({
type,
document,
documentAuthOptions,
recipient,
userId,
authOptions,
}: IsRecipientAuthorizedOptions): Promise<boolean> => {
const { derivedRecipientAccessAuth, derivedRecipientActionAuth } = extractDocumentAuthMethods({
documentAuth: document.authOptions,
documentAuth: documentAuthOptions,
recipientAuth: recipient.authOptions,
});

View File

@@ -12,7 +12,13 @@ import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
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 { DocumentStatus, RecipientRole, SendStatus } from '@documenso/prisma/client';
import {
DocumentSource,
DocumentStatus,
RecipientRole,
SendStatus,
SigningStatus,
} from '@documenso/prisma/client';
import { WebhookTriggerEvents } from '@documenso/prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
@@ -92,6 +98,8 @@ export const sendDocument = async ({
const { documentData } = document;
const isDirectTemplate = document.source === DocumentSource.TEMPLATE_DIRECT_LINK;
if (!documentData.data) {
throw new Error('Document data not found');
}
@@ -133,10 +141,21 @@ export const sendDocument = async ({
const { email, name } = recipient;
const selfSigner = email === user.email;
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role];
const recipientActionVerb = actionVerb.toLowerCase();
const selfSignerCustomEmail = `You have initiated the document ${`"${document.title}"`} that requires you to ${RECIPIENT_ROLES_DESCRIPTION[
recipient.role
].actionVerb.toLowerCase()} it.`;
let emailMessage = customEmail?.message || '';
let emailSubject = `Please ${recipientActionVerb} this document`;
if (selfSigner) {
emailMessage = `You have initiated the document ${`"${document.title}"`} that requires you to ${recipientActionVerb} it.`;
emailSubject = `Please ${recipientActionVerb} your document`;
}
if (isDirectTemplate) {
emailMessage = `A document was created by your direct template that requires you to ${recipientActionVerb} it.`;
emailSubject = `Please ${recipientActionVerb} this document created by your direct template`;
}
const customEmailTemplate = {
'signer.name': name,
@@ -153,22 +172,11 @@ export const sendDocument = async ({
inviterEmail: user.email,
assetBaseUrl,
signDocumentLink,
customBody: renderCustomEmailTemplate(
selfSigner && !customEmail?.message
? selfSignerCustomEmail
: customEmail?.message || '',
customEmailTemplate,
),
customBody: renderCustomEmailTemplate(emailMessage, customEmailTemplate),
role: recipient.role,
selfSigner,
});
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role];
const emailSubject = selfSigner
? `Please ${actionVerb.toLowerCase()} your document`
: `Please ${actionVerb.toLowerCase()} this document`;
await prisma.$transaction(
async (tx) => {
await mailer.sendMail({
@@ -220,7 +228,8 @@ export const sendDocument = async ({
}
const allRecipientsHaveNoActionToTake = document.Recipient.every(
(recipient) => recipient.role === RecipientRole.CC,
(recipient) =>
recipient.role === RecipientRole.CC || recipient.signingStatus === SigningStatus.SIGNED,
);
if (allRecipientsHaveNoActionToTake) {

View File

@@ -0,0 +1,52 @@
import type { Document, Field, Recipient } from '@documenso/prisma/client';
import { FieldType } from '@documenso/prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TRecipientActionAuth } from '../../types/document-auth';
import { extractDocumentAuthMethods } from '../../utils/document-auth';
import { isRecipientAuthorized } from './is-recipient-authorized';
export type ValidateFieldAuthOptions = {
documentAuthOptions: Document['authOptions'];
recipient: Pick<Recipient, 'authOptions' | 'email'>;
field: Field;
userId?: number;
authOptions?: TRecipientActionAuth;
};
/**
* Throws an error if the reauth for a field is invalid.
*
* Returns the derived recipient action authentication if valid.
*/
export const validateFieldAuth = async ({
documentAuthOptions,
recipient,
field,
userId,
authOptions,
}: ValidateFieldAuthOptions) => {
const { derivedRecipientActionAuth } = extractDocumentAuthMethods({
documentAuth: documentAuthOptions,
recipientAuth: recipient.authOptions,
});
// Override all non-signature fields to not require any auth.
if (field.type !== FieldType.SIGNATURE) {
return null;
}
const isValid = await isRecipientAuthorized({
type: 'ACTION',
documentAuthOptions,
recipient,
userId,
authOptions,
});
if (!isValid) {
throw new AppError(AppErrorCode.UNAUTHORIZED, 'Invalid authentication values');
}
return derivedRecipientActionAuth;
};

View File

@@ -8,13 +8,11 @@ import { DocumentStatus, FieldType, SigningStatus } from '@documenso/prisma/clie
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../../constants/date-formats';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '../../constants/time-zones';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import type { TRecipientActionAuth } from '../../types/document-auth';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { extractDocumentAuthMethods } from '../../utils/document-auth';
import { isRecipientAuthorized } from '../document/is-recipient-authorized';
import { validateFieldAuth } from '../document/validate-field-auth';
export type SignFieldWithTokenOptions = {
token: string;
@@ -26,6 +24,16 @@ export type SignFieldWithTokenOptions = {
requestMetadata?: RequestMetadata;
};
/**
* Please read.
*
* Content within this function has been duplicated in the
* createDocumentFromDirectTemplate file.
*
* Any update to this should be reflected in the other file if required.
*
* Todo: Extract common logic.
*/
export const signFieldWithToken = async ({
token,
fieldId,
@@ -79,33 +87,14 @@ export const signFieldWithToken = async ({
throw new Error(`Field ${fieldId} has no recipientId`);
}
let { derivedRecipientActionAuth } = extractDocumentAuthMethods({
documentAuth: document.authOptions,
recipientAuth: recipient.authOptions,
const derivedRecipientActionAuth = await validateFieldAuth({
documentAuthOptions: document.authOptions,
recipient,
field,
userId,
authOptions,
});
// Override all non-signature fields to not require any auth.
if (field.type !== FieldType.SIGNATURE) {
derivedRecipientActionAuth = null;
}
let isValid = true;
// Only require auth on signature fields for now.
if (field.type === FieldType.SIGNATURE) {
isValid = await isRecipientAuthorized({
type: 'ACTION',
document: document,
recipient: recipient,
userId,
authOptions,
});
}
if (!isValid) {
throw new AppError(AppErrorCode.UNAUTHORIZED, 'Invalid authentication values');
}
const documentMeta = await prisma.documentMeta.findFirst({
where: {
documentId: document.id,
@@ -142,10 +131,6 @@ export const signFieldWithToken = async ({
});
if (isSignatureField) {
if (!field.recipientId) {
throw new Error('Field has no recipientId');
}
const signature = await tx.signature.upsert({
where: {
fieldId: field.id,

View File

@@ -3,6 +3,10 @@ import { prisma } from '@documenso/prisma';
import type { Recipient } from '@documenso/prisma/client';
import { RecipientRole } from '@documenso/prisma/client';
import {
DIRECT_TEMPLATE_RECIPIENT_EMAIL,
DIRECT_TEMPLATE_RECIPIENT_NAME,
} from '../../constants/template';
import { AppError, AppErrorCode } from '../../errors/app-error';
import {
type TRecipientActionAuthTypes,
@@ -48,6 +52,9 @@ export const setRecipientsForTemplate = async ({
},
],
},
include: {
directLink: true,
},
});
if (!template) {
@@ -71,10 +78,21 @@ export const setRecipientsForTemplate = async ({
}
}
const normalizedRecipients = recipients.map((recipient) => ({
...recipient,
email: recipient.email.toLowerCase(),
}));
const normalizedRecipients = recipients.map((recipient) => {
// Force replace any changes to the name or email of the direct recipient.
if (template.directLink && recipient.id === template.directLink.directTemplateRecipientId) {
return {
...recipient,
email: DIRECT_TEMPLATE_RECIPIENT_EMAIL,
name: DIRECT_TEMPLATE_RECIPIENT_NAME,
};
}
return {
...recipient,
email: recipient.email.toLowerCase(),
};
});
const existingRecipients = await prisma.recipient.findMany({
where: {
@@ -90,6 +108,27 @@ export const setRecipientsForTemplate = async ({
),
);
if (template.directLink !== null) {
const updatedDirectRecipient = recipients.find(
(recipient) => recipient.id === template.directLink?.directTemplateRecipientId,
);
const deletedDirectRecipient = removedRecipients.find(
(recipient) => recipient.id === template.directLink?.directTemplateRecipientId,
);
if (updatedDirectRecipient?.role === RecipientRole.CC) {
throw new AppError(AppErrorCode.INVALID_BODY, 'Cannot set direct recipient as CC');
}
if (deletedDirectRecipient) {
throw new AppError(
AppErrorCode.INVALID_BODY,
'Cannot delete direct recipient while direct template exists',
);
}
}
const linkedRecipients = normalizedRecipients.map((recipient) => {
const existing = existingRecipients.find(
(existingRecipient) =>

View File

@@ -0,0 +1,526 @@
import { createElement } from 'react';
import { DateTime } from 'luxon';
import { match } from 'ts-pattern';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import { DocumentCreatedFromDirectTemplateEmailTemplate } from '@documenso/email/templates/document-created-from-direct-template';
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import type { Field, Signature } from '@documenso/prisma/client';
import {
DocumentSource,
DocumentStatus,
FieldType,
RecipientRole,
SendStatus,
SigningStatus,
} from '@documenso/prisma/client';
import type { TSignFieldWithTokenMutationSchema } from '@documenso/trpc/server/field-router/schema';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../../constants/date-formats';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '../../constants/time-zones';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import type { TRecipientActionAuthTypes } from '../../types/document-auth';
import { DocumentAccessAuth, ZRecipientAuthOptionsSchema } from '../../types/document-auth';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import type { CreateDocumentAuditLogDataResponse } from '../../utils/document-audit-logs';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import {
createDocumentAuthOptions,
createRecipientAuthOptions,
extractDocumentAuthMethods,
} from '../../utils/document-auth';
import { formatDocumentsPath } from '../../utils/teams';
import { sendDocument } from '../document/send-document';
import { validateFieldAuth } from '../document/validate-field-auth';
export type CreateDocumentFromDirectTemplateOptions = {
directRecipientEmail: string;
directTemplateToken: string;
signedFieldValues: TSignFieldWithTokenMutationSchema[];
templateUpdatedAt: Date;
requestMetadata: RequestMetadata;
user?: {
id: number;
name?: string;
email: string;
};
};
type CreatedDirectRecipientField = {
field: Field & { Signature?: Signature | null };
derivedRecipientActionAuth: TRecipientActionAuthTypes | null;
};
export const createDocumentFromDirectTemplate = async ({
directRecipientEmail,
directTemplateToken,
signedFieldValues,
templateUpdatedAt,
requestMetadata,
user,
}: CreateDocumentFromDirectTemplateOptions) => {
const template = await prisma.template.findFirst({
where: {
directLink: {
token: directTemplateToken,
},
},
include: {
Recipient: {
include: {
Field: true,
},
},
directLink: true,
templateDocumentData: true,
templateMeta: true,
User: true,
},
});
if (!template?.directLink?.enabled) {
throw new AppError(AppErrorCode.INVALID_REQUEST, 'Invalid or missing template');
}
const { Recipient: recipients, directLink, User: templateOwner } = template;
const directTemplateRecipient = recipients.find(
(recipient) => recipient.id === directLink.directTemplateRecipientId,
);
if (!directTemplateRecipient || directTemplateRecipient.role === RecipientRole.CC) {
throw new AppError(AppErrorCode.INVALID_REQUEST, 'Invalid or missing direct recipient');
}
if (template.updatedAt.getTime() !== templateUpdatedAt.getTime()) {
throw new AppError(AppErrorCode.INVALID_REQUEST, 'Template no longer matches');
}
if (user && user.email !== directRecipientEmail) {
throw new AppError(AppErrorCode.INVALID_REQUEST, 'Email must match if you are logged in');
}
const { derivedRecipientAccessAuth, documentAuthOption: templateAuthOptions } =
extractDocumentAuthMethods({
documentAuth: template.authOptions,
});
const directRecipientName = user?.name;
// Ensure typesafety when we add more options.
const isAccessAuthValid = match(derivedRecipientAccessAuth)
.with(DocumentAccessAuth.ACCOUNT, () => user && user?.email === directRecipientEmail)
.with(null, () => true)
.exhaustive();
if (!isAccessAuthValid) {
throw new AppError(AppErrorCode.UNAUTHORIZED, 'You must be logged in');
}
const directTemplateRecipientAuthOptions = ZRecipientAuthOptionsSchema.parse(
directTemplateRecipient.authOptions,
);
const nonDirectTemplateRecipients = template.Recipient.filter(
(recipient) => recipient.id !== directTemplateRecipient.id,
);
const metaTimezone = template.templateMeta?.timezone || DEFAULT_DOCUMENT_TIME_ZONE;
const metaDateFormat = template.templateMeta?.dateFormat || DEFAULT_DOCUMENT_DATE_FORMAT;
// Associate, validate and map to a query every direct template recipient field with the provided fields.
const createDirectRecipientFieldArgs = await Promise.all(
directTemplateRecipient.Field.map(async (templateField) => {
const signedFieldValue = signedFieldValues.find(
(value) => value.fieldId === templateField.id,
);
if (!signedFieldValue) {
throw new AppError(AppErrorCode.INVALID_BODY, 'Invalid, missing or changed fields');
}
if (templateField.type === FieldType.NAME && directRecipientName === undefined) {
directRecipientName === signedFieldValue.value;
}
const derivedRecipientActionAuth = await validateFieldAuth({
documentAuthOptions: template.authOptions,
recipient: {
authOptions: directTemplateRecipient.authOptions,
email: directRecipientEmail,
},
field: templateField,
userId: user?.id,
authOptions: signedFieldValue.authOptions,
});
const { value, isBase64 } = signedFieldValue;
const isSignatureField =
templateField.type === FieldType.SIGNATURE ||
templateField.type === FieldType.FREE_SIGNATURE;
let customText = !isSignatureField ? value : '';
const signatureImageAsBase64 = isSignatureField && isBase64 ? value : undefined;
const typedSignature = isSignatureField && !isBase64 ? value : undefined;
if (templateField.type === FieldType.DATE) {
customText = DateTime.now().setZone(metaTimezone).toFormat(metaDateFormat);
}
if (isSignatureField && !signatureImageAsBase64 && !typedSignature) {
throw new Error('Signature field must have a signature');
}
return {
templateField,
customText,
derivedRecipientActionAuth,
signature: isSignatureField
? {
signatureImageAsBase64,
typedSignature,
}
: null,
};
}),
);
const directTemplateNonSignatureFields = createDirectRecipientFieldArgs.filter(
({ signature }) => signature === null,
);
const directTemplateSignatureFields = createDirectRecipientFieldArgs.filter(
({ signature }) => signature !== null,
);
const initialRequestTime = new Date();
const { documentId, directRecipientToken } = await prisma.$transaction(async (tx) => {
const documentData = await tx.documentData.create({
data: {
type: template.templateDocumentData.type,
data: template.templateDocumentData.data,
initialData: template.templateDocumentData.initialData,
},
});
// Create the document and non direct template recipients.
const document = await tx.document.create({
data: {
source: DocumentSource.TEMPLATE_DIRECT_LINK,
templateId: template.id,
userId: template.userId,
teamId: template.teamId,
title: template.title,
createdAt: initialRequestTime,
status: DocumentStatus.PENDING,
documentDataId: documentData.id,
authOptions: createDocumentAuthOptions({
globalAccessAuth: templateAuthOptions.globalAccessAuth,
globalActionAuth: templateAuthOptions.globalActionAuth,
}),
Recipient: {
createMany: {
data: nonDirectTemplateRecipients.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,
}),
sendStatus:
recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT,
signingStatus:
recipient.role === RecipientRole.CC
? SigningStatus.SIGNED
: SigningStatus.NOT_SIGNED,
token: nanoid(),
};
}),
},
},
},
include: {
Recipient: true,
team: {
select: {
url: true,
},
},
},
});
let nonDirectRecipientFieldsToCreate: Omit<Field, 'id' | 'secondaryId' | 'templateId'>[] = [];
Object.values(nonDirectTemplateRecipients).forEach((templateRecipient) => {
const recipient = document.Recipient.find(
(recipient) => recipient.email === templateRecipient.email,
);
if (!recipient) {
throw new Error('Recipient not found.');
}
nonDirectRecipientFieldsToCreate = nonDirectRecipientFieldsToCreate.concat(
templateRecipient.Field.map((field) => ({
documentId: document.id,
recipientId: recipient.id,
type: field.type,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
customText: '',
inserted: false,
})),
);
});
await tx.field.createMany({
data: nonDirectRecipientFieldsToCreate,
});
// Create the direct recipient and their non signature fields.
const createdDirectRecipient = await tx.recipient.create({
data: {
documentId: document.id,
email: directRecipientEmail,
name: directRecipientName,
authOptions: createRecipientAuthOptions({
accessAuth: directTemplateRecipientAuthOptions.accessAuth,
actionAuth: directTemplateRecipientAuthOptions.actionAuth,
}),
role: directTemplateRecipient.role,
token: nanoid(),
signingStatus: SigningStatus.SIGNED,
sendStatus: SendStatus.SENT,
signedAt: initialRequestTime,
Field: {
createMany: {
data: directTemplateNonSignatureFields.map(({ templateField, customText }) => ({
documentId: document.id,
type: templateField.type,
page: templateField.page,
positionX: templateField.positionX,
positionY: templateField.positionY,
width: templateField.width,
height: templateField.height,
customText,
inserted: true,
})),
},
},
},
include: {
Field: true,
},
});
// 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.');
}
const field = await tx.field.create({
data: {
documentId: document.id,
recipientId: createdDirectRecipient.id,
type: templateField.type,
page: templateField.page,
positionX: templateField.positionX,
positionY: templateField.positionY,
width: templateField.width,
height: templateField.height,
customText: '',
inserted: true,
Signature: {
create: {
recipientId: createdDirectRecipient.id,
signatureImageAsBase64: signature.signatureImageAsBase64,
typedSignature: signature.typedSignature,
},
},
},
include: {
Signature: true,
},
});
return {
field,
derivedRecipientActionAuth,
};
},
),
);
const createdDirectRecipientFields: CreatedDirectRecipientField[] = [
...createdDirectRecipient.Field.map((field) => ({
field,
derivedRecipientActionAuth: null,
})),
...createdDirectRecipientSignatureFields,
];
/**
* Create the following audit logs.
* - DOCUMENT_CREATED
* - DOCUMENT_FIELD_INSERTED
* - DOCUMENT_RECIPIENT_COMPLETED
*/
const auditLogsToCreate: CreateDocumentAuditLogDataResponse[] = [
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED,
documentId: document.id,
user: {
id: user?.id,
name: user?.name,
email: directRecipientEmail,
},
requestMetadata,
data: {
title: document.title,
source: {
type: DocumentSource.TEMPLATE_DIRECT_LINK,
templateId: template.id,
directRecipientEmail,
},
},
}),
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED,
documentId: document.id,
user: {
id: user?.id,
name: user?.name,
email: directRecipientEmail,
},
requestMetadata,
data: {
recipientEmail: createdDirectRecipient.email,
recipientId: createdDirectRecipient.id,
recipientName: createdDirectRecipient.name,
recipientRole: createdDirectRecipient.role,
accessAuth: derivedRecipientAccessAuth || undefined,
},
}),
...createdDirectRecipientFields.map(({ field, derivedRecipientActionAuth }) =>
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED,
documentId: document.id,
user: {
id: user?.id,
name: user?.name,
email: directRecipientEmail,
},
requestMetadata,
data: {
recipientEmail: createdDirectRecipient.email,
recipientId: createdDirectRecipient.id,
recipientName: createdDirectRecipient.name,
recipientRole: createdDirectRecipient.role,
fieldId: field.secondaryId,
field: match(field.type)
.with(FieldType.SIGNATURE, FieldType.FREE_SIGNATURE, (type) => ({
type,
data:
field.Signature?.signatureImageAsBase64 || field.Signature?.typedSignature || '',
}))
.with(FieldType.DATE, FieldType.EMAIL, FieldType.NAME, FieldType.TEXT, (type) => ({
type,
data: field.customText,
}))
.exhaustive(),
fieldSecurity: derivedRecipientActionAuth
? {
type: derivedRecipientActionAuth,
}
: undefined,
},
}),
),
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
documentId: document.id,
user: {
id: user?.id,
name: user?.name,
email: directRecipientEmail,
},
requestMetadata,
data: {
recipientEmail: createdDirectRecipient.email,
recipientId: createdDirectRecipient.id,
recipientName: createdDirectRecipient.name,
recipientRole: createdDirectRecipient.role,
},
}),
];
await tx.documentAuditLog.createMany({
data: auditLogsToCreate,
});
// Send email to template owner.
const emailTemplate = createElement(DocumentCreatedFromDirectTemplateEmailTemplate, {
recipientName: directRecipientEmail,
documentLink: `${formatDocumentsPath(document.team?.url)}/${document.id}`,
documentName: document.title,
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000',
});
await mailer.sendMail({
to: [
{
name: templateOwner.name || '',
address: templateOwner.email,
},
],
from: {
name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso',
address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com',
},
subject: 'Document created from direct template',
html: render(emailTemplate),
text: render(emailTemplate, { plainText: true }),
});
return {
documentId: document.id,
directRecipientToken: createdDirectRecipient.token,
};
});
try {
// This handles sending emails and sealing the document if required.
await sendDocument({
documentId,
userId: template.userId,
teamId: template.teamId || undefined,
requestMetadata,
});
} catch (err) {
console.error('[CREATE_DOCUMENT_FROM_DIRECT_TEMPLATE]:', err);
// Don't launch an error since the document has already been created.
// Log and reseal as required until we configure middleware.
}
return directRecipientToken;
};

View File

@@ -1,6 +1,6 @@
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import type { RecipientRole } from '@documenso/prisma/client';
import { DocumentSource, type RecipientRole } from '@documenso/prisma/client';
export type CreateDocumentFromTemplateLegacyOptions = {
templateId: number;
@@ -62,6 +62,8 @@ export const createDocumentFromTemplateLegacy = async ({
const document = await prisma.document.create({
data: {
source: DocumentSource.TEMPLATE,
templateId: template.id,
userId,
teamId: template.teamId,
title: template.title,

View File

@@ -1,7 +1,14 @@
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import type { Field } from '@documenso/prisma/client';
import { type Recipient, WebhookTriggerEvents } from '@documenso/prisma/client';
import {
DocumentSource,
type Recipient,
RecipientRole,
SendStatus,
SigningStatus,
WebhookTriggerEvents,
} from '@documenso/prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
@@ -139,6 +146,8 @@ export const createDocumentFromTemplate = async ({
return await prisma.$transaction(async (tx) => {
const document = await tx.document.create({
data: {
source: DocumentSource.TEMPLATE,
templateId: template.id,
userId,
teamId: template.teamId,
title: override?.title || template.title,
@@ -170,6 +179,12 @@ 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,
token: nanoid(),
};
}),
@@ -223,6 +238,10 @@ export const createDocumentFromTemplate = async ({
requestMetadata,
data: {
title: document.title,
source: {
type: DocumentSource.TEMPLATE,
templateId: template.id,
},
},
}),
});

View File

@@ -0,0 +1,107 @@
'use server';
import { nanoid } from 'nanoid';
import { prisma } from '@documenso/prisma';
import type { Recipient, TemplateDirectLink } from '@documenso/prisma/client';
import {
DIRECT_TEMPLATE_RECIPIENT_EMAIL,
DIRECT_TEMPLATE_RECIPIENT_NAME,
} from '../../constants/template';
import { AppError, AppErrorCode } from '../../errors/app-error';
export type CreateTemplateDirectLinkOptions = {
templateId: number;
userId: number;
directRecipientId?: number;
};
export const createTemplateDirectLink = async ({
templateId,
userId,
directRecipientId,
}: CreateTemplateDirectLinkOptions): Promise<TemplateDirectLink> => {
const template = await prisma.template.findFirst({
where: {
id: templateId,
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
},
},
},
},
],
},
include: {
Recipient: true,
directLink: true,
},
});
if (!template) {
throw new AppError(AppErrorCode.NOT_FOUND, 'Template not found');
}
if (template.directLink) {
throw new AppError(AppErrorCode.ALREADY_EXISTS, 'Direct template already exists');
}
if (
directRecipientId &&
!template.Recipient.find((recipient) => recipient.id === directRecipientId)
) {
throw new AppError(AppErrorCode.NOT_FOUND, 'Recipient not found');
}
if (
!directRecipientId &&
template.Recipient.find(
(recipient) => recipient.email.toLowerCase() === DIRECT_TEMPLATE_RECIPIENT_EMAIL,
)
) {
throw new AppError(AppErrorCode.INVALID_BODY, 'Cannot generate placeholder direct recipient');
}
return await prisma.$transaction(async (tx) => {
let recipient: Recipient | undefined;
if (directRecipientId) {
recipient = await tx.recipient.update({
where: {
templateId,
id: directRecipientId,
},
data: {
name: DIRECT_TEMPLATE_RECIPIENT_NAME,
email: DIRECT_TEMPLATE_RECIPIENT_EMAIL,
},
});
} else {
recipient = await tx.recipient.create({
data: {
templateId,
name: DIRECT_TEMPLATE_RECIPIENT_NAME,
email: DIRECT_TEMPLATE_RECIPIENT_EMAIL,
token: nanoid(),
},
});
}
return await tx.templateDirectLink.create({
data: {
templateId,
enabled: true,
token: nanoid(),
directTemplateRecipientId: recipient.id,
},
});
});
};

View File

@@ -0,0 +1,68 @@
'use server';
import { generateAvaliableRecipientPlaceholder } from '@documenso/lib/utils/templates';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
export type DeleteTemplateDirectLinkOptions = {
templateId: number;
userId: number;
};
export const deleteTemplateDirectLink = async ({
templateId,
userId,
}: DeleteTemplateDirectLinkOptions): Promise<void> => {
const template = await prisma.template.findFirst({
where: {
id: templateId,
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
},
},
},
},
],
},
include: {
directLink: true,
Recipient: true,
},
});
if (!template) {
throw new AppError(AppErrorCode.NOT_FOUND, 'Template not found');
}
const { directLink } = template;
if (!directLink) {
return;
}
await prisma.$transaction(async (tx) => {
await tx.recipient.update({
where: {
templateId: template.id,
id: directLink.directTemplateRecipientId,
},
data: {
...generateAvaliableRecipientPlaceholder(template.Recipient),
},
});
await tx.templateDirectLink.delete({
where: {
templateId,
},
});
});
};

View File

@@ -8,6 +8,9 @@ export type FindTemplatesOptions = {
perPage: number;
};
export type FindTemplatesResponse = Awaited<ReturnType<typeof findTemplates>>;
export type FindTemplateRow = FindTemplatesResponse['templates'][number];
export const findTemplates = async ({
userId,
teamId,
@@ -45,6 +48,12 @@ export const findTemplates = async ({
},
Field: true,
Recipient: true,
directLink: {
select: {
token: true,
enabled: true,
},
},
},
skip: Math.max(page - 1, 0) * perPage,
orderBy: {

View File

@@ -0,0 +1,33 @@
import { prisma } from '@documenso/prisma';
export interface GetTemplateByDirectLinkTokenOptions {
token: string;
}
export const getTemplateByDirectLinkToken = async ({
token,
}: GetTemplateByDirectLinkTokenOptions) => {
const template = await prisma.template.findFirstOrThrow({
where: {
directLink: {
token,
enabled: true,
},
},
include: {
directLink: true,
Recipient: {
include: {
Field: true,
},
},
templateDocumentData: true,
templateMeta: true,
},
});
return {
...template,
Field: template.Recipient.map((recipient) => recipient.Field).flat(),
};
};

View File

@@ -29,6 +29,7 @@ export const getTemplateWithDetailsById = async ({
],
},
include: {
directLink: true,
templateDocumentData: true,
templateMeta: true,
Recipient: true,

View File

@@ -0,0 +1,61 @@
'use server';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
export type ToggleTemplateDirectLinkOptions = {
templateId: number;
userId: number;
enabled: boolean;
};
export const toggleTemplateDirectLink = async ({
templateId,
userId,
enabled,
}: ToggleTemplateDirectLinkOptions) => {
const template = await prisma.template.findFirst({
where: {
id: templateId,
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
},
},
},
},
],
},
include: {
Recipient: true,
directLink: true,
},
});
if (!template) {
throw new AppError(AppErrorCode.NOT_FOUND, 'Template not found');
}
const { directLink } = template;
if (!directLink) {
throw new AppError(AppErrorCode.NOT_FOUND, 'Direct template link not found');
}
return await prisma.templateDirectLink.update({
where: {
id: directLink.id,
},
data: {
templateId: template.id,
enabled,
},
});
};

View File

@@ -6,7 +6,7 @@
/////////////////////////////////////////////////////////////////////////////////////////////
import { z } from 'zod';
import { FieldType } from '@documenso/prisma/client';
import { DocumentSource, FieldType } from '@documenso/prisma/client';
import { ZRecipientActionAuthTypesSchema } from './document-auth';
@@ -192,6 +192,22 @@ export const ZDocumentAuditLogEventDocumentCreatedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED),
data: z.object({
title: z.string(),
source: z
.union([
z.object({
type: z.literal(DocumentSource.DOCUMENT),
}),
z.object({
type: z.literal(DocumentSource.TEMPLATE),
templateId: z.number(),
}),
z.object({
type: z.literal(DocumentSource.TEMPLATE_DIRECT_LINK),
templateId: z.number(),
directRecipientEmail: z.string().email(),
}),
])
.optional(),
}),
});

View File

@@ -0,0 +1,44 @@
import type { Recipient } from '@documenso/prisma/client';
import { WEBAPP_BASE_URL } from '../constants/app';
export const formatDirectTemplatePath = (token: string) => {
return `${WEBAPP_BASE_URL}/d/${token}`;
};
/**
* Generate a placeholder recipient using an index number.
*
* May collide with existing recipients.
*
* Note:
* - Update TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX if this is ever changed.
* - Update TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX if this is ever changed.
*
*/
export const generateRecipientPlaceholder = (index: number) => {
return {
name: `Recipient ${index}`,
email: `recipient.${index}@documenso.com`,
};
};
/**
* Generates a placeholder that does not collide with any existing recipients.
*
* @param currentRecipients The current recipients that exist for a template.
*/
export const generateAvaliableRecipientPlaceholder = (currentRecipients: Recipient[]) => {
const recipientEmails = currentRecipients.map((recipient) => recipient.email);
let recipientPlaceholder = generateRecipientPlaceholder(0);
for (let i = 1; i <= currentRecipients.length + 1; i++) {
recipientPlaceholder = generateRecipientPlaceholder(i);
if (!recipientEmails.includes(recipientPlaceholder.email)) {
return recipientPlaceholder;
}
}
return recipientPlaceholder;
};