diff --git a/apps/web/src/app/(dashboard)/templates/[id]/template-page-view.tsx b/apps/web/src/app/(dashboard)/templates/[id]/template-page-view.tsx index 899e600f1..7bb9640c5 100644 --- a/apps/web/src/app/(dashboard)/templates/[id]/template-page-view.tsx +++ b/apps/web/src/app/(dashboard)/templates/[id]/template-page-view.tsx @@ -58,7 +58,7 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps) ]); return ( -
+
Templates diff --git a/apps/web/src/app/(dashboard)/templates/use-template-dialog.tsx b/apps/web/src/app/(dashboard)/templates/use-template-dialog.tsx index e4c703a2f..7000e795a 100644 --- a/apps/web/src/app/(dashboard)/templates/use-template-dialog.tsx +++ b/apps/web/src/app/(dashboard)/templates/use-template-dialog.tsx @@ -1,14 +1,16 @@ import { useRouter } from 'next/navigation'; import { zodResolver } from '@hookform/resolvers/zod'; -import { Plus } from 'lucide-react'; -import { Controller, useFieldArray, useForm } from 'react-hook-form'; +import { InfoIcon, Plus } from 'lucide-react'; +import { useFieldArray, useForm } from 'react-hook-form'; import * as z from 'zod'; +import { TEMPLATE_RECIPIENT_PLACEHOLDER_REGEX } from '@documenso/lib/constants/template'; +import { AppError } from '@documenso/lib/errors/app-error'; import type { Recipient } from '@documenso/prisma/client'; -import { RecipientRole } from '@documenso/prisma/client'; import { trpc } from '@documenso/trpc/react'; import { Button } from '@documenso/ui/primitives/button'; +import { Checkbox } from '@documenso/ui/primitives/checkbox'; import { Dialog, DialogClose, @@ -19,24 +21,59 @@ import { DialogTitle, DialogTrigger, } from '@documenso/ui/primitives/dialog'; -import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; import { Input } from '@documenso/ui/primitives/input'; -import { Label } from '@documenso/ui/primitives/label'; -import { ROLE_ICONS } from '@documenso/ui/primitives/recipient-role-icons'; -import { Select, SelectContent, SelectItem, SelectTrigger } from '@documenso/ui/primitives/select'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip'; +import type { Toast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast'; import { useOptionalCurrentTeam } from '~/providers/team'; -const ZAddRecipientsForNewDocumentSchema = z.object({ - recipients: z.array( - z.object({ - email: z.string().email(), - name: z.string(), - role: z.nativeEnum(RecipientRole), - }), - ), -}); +const ZAddRecipientsForNewDocumentSchema = z + .object({ + sendDocument: z.boolean(), + recipients: z.array( + z.object({ + id: z.number(), + email: z.string().email(), + name: z.string(), + }), + ), + }) + // Display exactly which rows are duplicates. + .superRefine((items, ctx) => { + const uniqueEmails = new Map(); + + for (const [index, recipients] of items.recipients.entries()) { + const email = recipients.email.toLowerCase(); + + const firstFoundIndex = uniqueEmails.get(email); + + if (firstFoundIndex === undefined) { + uniqueEmails.set(email, index); + continue; + } + + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Emails must be unique', + path: ['recipients', index, 'email'], + }); + + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Emails must be unique', + path: ['recipients', firstFoundIndex, 'email'], + }); + } + }); type TAddRecipientsForNewDocumentSchema = z.infer; @@ -56,33 +93,31 @@ export function UseTemplateDialog({ const team = useOptionalCurrentTeam(); - const { - control, - handleSubmit, - formState: { errors, isSubmitting }, - } = useForm({ + const form = useForm({ resolver: zodResolver(ZAddRecipientsForNewDocumentSchema), defaultValues: { - recipients: - recipients.length > 0 - ? recipients.map((recipient) => ({ - nativeId: recipient.id, - formId: String(recipient.id), - name: recipient.name, - email: recipient.email, - role: recipient.role, - })) - : [ - { - name: '', - email: '', - role: RecipientRole.SIGNER, - }, - ], + sendDocument: false, + recipients: recipients.map((recipient) => { + const isRecipientPlaceholder = recipient.email.match(TEMPLATE_RECIPIENT_PLACEHOLDER_REGEX); + + if (isRecipientPlaceholder) { + return { + id: recipient.id, + name: '', + email: '', + }; + } + + return { + id: recipient.id, + name: recipient.name, + email: recipient.email, + }; + }), }, }); - const { mutateAsync: createDocumentFromTemplate, isLoading: isCreatingDocumentFromTemplate } = + const { mutateAsync: createDocumentFromTemplate } = trpc.template.createDocumentFromTemplate.useMutation(); const onSubmit = async (data: TAddRecipientsForNewDocumentSchema) => { @@ -91,6 +126,7 @@ export function UseTemplateDialog({ templateId, teamId: team?.id, recipients: data.recipients, + sendDocument: data.sendDocument, }); toast({ @@ -101,18 +137,24 @@ export function UseTemplateDialog({ router.push(`${documentRootPath}/${id}`); } catch (err) { - toast({ + const error = AppError.parseError(err); + + const toastPayload: Toast = { title: 'Error', description: 'An error occurred while creating document from template.', variant: 'destructive', - }); + }; + + if (error.code === 'DOCUMENT_SEND_FAILED') { + toastPayload.description = 'The document was created but could not be sent to recipients.'; + } + + toast(toastPayload); } }; - const onCreateDocumentFromTemplate = handleSubmit(onSubmit); - const { fields: formRecipients } = useFieldArray({ - control, + control: form.control, name: 'recipients', }); @@ -126,121 +168,110 @@ export function UseTemplateDialog({ - Document Recipients - Add the recipients to create the template with. + Create document from template + + {recipients.length === 0 + ? 'A draft document will be created' + : 'Add the recipients to create the document with'} + -
- {formRecipients.map((recipient, index) => ( -
-
- - ( - +
+
+
+ {formRecipients.map((recipient, index) => ( +
+ ( + + {index === 0 && Email} + + + + + + + )} /> - )} - /> -
-
- + ( + + {index === 0 && Name} - ( - + + + + + )} /> - )} - /> +
+ ))}
-
- ( - - )} - /> -
+ + + + -
- - -
-
- ))} -
- - - - - - - - + + + + + ); diff --git a/packages/api/v1/implementation.ts b/packages/api/v1/implementation.ts index b1e069e35..ee8bf5996 100644 --- a/packages/api/v1/implementation.ts +++ b/packages/api/v1/implementation.ts @@ -19,7 +19,7 @@ import { getRecipientById } from '@documenso/lib/server-only/recipient/get-recip import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document'; import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document'; import { updateRecipient } from '@documenso/lib/server-only/recipient/update-recipient'; -import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template'; +import { createDocumentFromTemplateLegacy } from '@documenso/lib/server-only/template/create-document-from-template-legacy'; import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { getFile } from '@documenso/lib/universal/upload/get-file'; import { putPdfFile } from '@documenso/lib/universal/upload/put-file'; @@ -286,7 +286,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { const fileName = body.title.endsWith('.pdf') ? body.title : `${body.title}.pdf`; - const document = await createDocumentFromTemplate({ + const document = await createDocumentFromTemplateLegacy({ templateId, userId: user.id, teamId: team?.id, diff --git a/packages/app-tests/e2e/templates/manage-templates.spec.ts b/packages/app-tests/e2e/templates/manage-templates.spec.ts index a298d1e38..7d75c4f65 100644 --- a/packages/app-tests/e2e/templates/manage-templates.spec.ts +++ b/packages/app-tests/e2e/templates/manage-templates.spec.ts @@ -189,7 +189,14 @@ test('[TEMPLATES]: use template', async ({ page }) => { // Use personal template. await page.getByRole('button', { name: 'Use Template' }).click(); - await page.getByRole('button', { name: 'Create Document' }).click(); + + // Enter template values. + await page.getByPlaceholder('recipient.1@documenso.com').click(); + await page.getByPlaceholder('recipient.1@documenso.com').fill(teamMemberUser.email); + await page.getByPlaceholder('Recipient 1').click(); + await page.getByPlaceholder('Recipient 1').fill('name'); + + await page.getByRole('button', { name: 'Create as draft' }).click(); await page.waitForURL(/documents/); await page.getByRole('main').getByRole('link', { name: 'Documents' }).click(); await page.waitForURL('/documents'); @@ -200,7 +207,14 @@ test('[TEMPLATES]: use template', async ({ page }) => { // Use team template. await page.getByRole('button', { name: 'Use Template' }).click(); - await page.getByRole('button', { name: 'Create Document' }).click(); + + // Enter template values. + await page.getByPlaceholder('recipient.1@documenso.com').click(); + await page.getByPlaceholder('recipient.1@documenso.com').fill(teamMemberUser.email); + await page.getByPlaceholder('Recipient 1').click(); + await page.getByPlaceholder('Recipient 1').fill('name'); + + await page.getByRole('button', { name: 'Create as draft' }).click(); await page.waitForURL(/\/t\/.+\/documents/); await page.getByRole('main').getByRole('link', { name: 'Documents' }).click(); await page.waitForURL(`/t/${team.url}/documents`); diff --git a/packages/lib/constants/template.ts b/packages/lib/constants/template.ts new file mode 100644 index 000000000..80dee97cf --- /dev/null +++ b/packages/lib/constants/template.ts @@ -0,0 +1 @@ +export const TEMPLATE_RECIPIENT_PLACEHOLDER_REGEX = /recipient\.\d+@documenso\.com/i; diff --git a/packages/lib/errors/app-error.ts b/packages/lib/errors/app-error.ts index 120df5ed6..b48e45d54 100644 --- a/packages/lib/errors/app-error.ts +++ b/packages/lib/errors/app-error.ts @@ -1,4 +1,5 @@ import { TRPCError } from '@trpc/server'; +import { match } from 'ts-pattern'; import { z } from 'zod'; import { TRPCClientError } from '@documenso/trpc/client'; @@ -149,4 +150,24 @@ export class AppError extends Error { return null; } } + + static toRestAPIError(err: unknown): { + status: 400 | 401 | 404 | 500; + body: { message: string }; + } { + const error = AppError.parseError(err); + + const status = match(error.code) + .with(AppErrorCode.INVALID_BODY, AppErrorCode.INVALID_REQUEST, () => 400 as const) + .with(AppErrorCode.UNAUTHORIZED, () => 401 as const) + .with(AppErrorCode.NOT_FOUND, () => 404 as const) + .otherwise(() => 500 as const); + + return { + status, + body: { + message: status !== 500 ? error.message : 'Something went wrong', + }, + }; + } } diff --git a/packages/lib/server-only/template/create-document-from-template-legacy.ts b/packages/lib/server-only/template/create-document-from-template-legacy.ts new file mode 100644 index 000000000..fadbae4c3 --- /dev/null +++ b/packages/lib/server-only/template/create-document-from-template-legacy.ts @@ -0,0 +1,144 @@ +import { nanoid } from '@documenso/lib/universal/id'; +import { prisma } from '@documenso/prisma'; +import type { RecipientRole } from '@documenso/prisma/client'; + +export type CreateDocumentFromTemplateLegacyOptions = { + templateId: number; + userId: number; + teamId?: number; + recipients?: { + name?: string; + email: string; + role?: RecipientRole; + }[]; +}; + +/** + * Legacy server function for /api/v1 + */ +export const createDocumentFromTemplateLegacy = async ({ + templateId, + userId, + teamId, + recipients, +}: CreateDocumentFromTemplateLegacyOptions) => { + const template = await prisma.template.findUnique({ + where: { + id: templateId, + ...(teamId + ? { + team: { + id: teamId, + members: { + some: { + userId, + }, + }, + }, + } + : { + userId, + teamId: null, + }), + }, + include: { + Recipient: true, + Field: true, + templateDocumentData: true, + }, + }); + + if (!template) { + throw new Error('Template not found.'); + } + + const documentData = await prisma.documentData.create({ + data: { + type: template.templateDocumentData.type, + data: template.templateDocumentData.data, + initialData: template.templateDocumentData.initialData, + }, + }); + + const document = await prisma.document.create({ + data: { + userId, + teamId: template.teamId, + title: template.title, + documentDataId: documentData.id, + Recipient: { + create: template.Recipient.map((recipient) => ({ + email: recipient.email, + name: recipient.name, + role: recipient.role, + token: nanoid(), + })), + }, + }, + + include: { + Recipient: { + orderBy: { + id: 'asc', + }, + }, + documentData: true, + }, + }); + + await prisma.field.createMany({ + data: template.Field.map((field) => { + const recipient = template.Recipient.find((recipient) => recipient.id === field.recipientId); + + const documentRecipient = document.Recipient.find((doc) => doc.email === recipient?.email); + + if (!documentRecipient) { + throw new Error('Recipient not found.'); + } + + return { + type: field.type, + page: field.page, + positionX: field.positionX, + positionY: field.positionY, + width: field.width, + height: field.height, + customText: field.customText, + inserted: field.inserted, + documentId: document.id, + recipientId: documentRecipient.id, + }; + }), + }); + + if (recipients && recipients.length > 0) { + document.Recipient = await Promise.all( + recipients.map(async (recipient, index) => { + const existingRecipient = document.Recipient.at(index); + + return await prisma.recipient.upsert({ + where: { + documentId_email: { + documentId: document.id, + email: existingRecipient?.email ?? recipient.email, + }, + }, + update: { + name: recipient.name, + email: recipient.email, + role: recipient.role, + }, + create: { + documentId: document.id, + email: recipient.email, + name: recipient.name, + role: recipient.role, + token: nanoid(), + }, + }); + }), + ); + } + + return document; +}; diff --git a/packages/lib/server-only/template/create-document-from-template.ts b/packages/lib/server-only/template/create-document-from-template.ts index 79a3f6f25..7cd098d6d 100644 --- a/packages/lib/server-only/template/create-document-from-template.ts +++ b/packages/lib/server-only/template/create-document-from-template.ts @@ -1,16 +1,29 @@ import { nanoid } from '@documenso/lib/universal/id'; import { prisma } from '@documenso/prisma'; -import type { RecipientRole } from '@documenso/prisma/client'; +import type { Field } from '@documenso/prisma/client'; +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 type { RequestMetadata } from '../../universal/extract-request-metadata'; +import { createDocumentAuditLogData } from '../../utils/document-audit-logs'; +import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; + +type FinalRecipient = Pick & { + templateRecipientId: number; + fields: Field[]; +}; export type CreateDocumentFromTemplateOptions = { templateId: number; userId: number; teamId?: number; - recipients?: { + recipients: { + id: number; name?: string; email: string; - role?: RecipientRole; }[]; + requestMetadata?: RequestMetadata; }; export const createDocumentFromTemplate = async ({ @@ -18,7 +31,14 @@ export const createDocumentFromTemplate = async ({ userId, teamId, recipients, + requestMetadata, }: CreateDocumentFromTemplateOptions) => { + const user = await prisma.user.findFirstOrThrow({ + where: { + id: userId, + }, + }); + const template = await prisma.template.findUnique({ where: { id: templateId, @@ -39,16 +59,42 @@ export const createDocumentFromTemplate = async ({ }), }, include: { - Recipient: true, - Field: true, + Recipient: { + include: { + Field: true, + }, + }, templateDocumentData: true, }, }); if (!template) { - throw new Error('Template not found.'); + 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); + + if (!foundRecipient) { + throw new AppError( + AppErrorCode.INVALID_BODY, + `Missing template recipient with ID ${templateRecipient.id}`, + ); + } + + return { + templateRecipientId: templateRecipient.id, + fields: templateRecipient.Field, + name: foundRecipient.name ?? '', + email: foundRecipient.email, + role: templateRecipient.role, + }; + }); + const documentData = await prisma.documentData.create({ data: { type: template.templateDocumentData.type, @@ -57,85 +103,82 @@ export const createDocumentFromTemplate = async ({ }, }); - const document = await prisma.document.create({ - data: { - userId, - teamId: template.teamId, - title: template.title, - documentDataId: documentData.id, - Recipient: { - create: template.Recipient.map((recipient) => ({ - email: recipient.email, - name: recipient.name, - role: recipient.role, - token: nanoid(), - })), - }, - }, - - include: { - Recipient: { - orderBy: { - id: 'asc', + return await prisma.$transaction(async (tx) => { + const document = await tx.document.create({ + data: { + userId, + teamId: template.teamId, + title: template.title, + documentDataId: documentData.id, + Recipient: { + createMany: { + data: finalRecipients.map((recipient) => ({ + email: recipient.email, + name: recipient.name, + role: recipient.role, + token: nanoid(), + })), + }, }, }, - documentData: true, - }, - }); + include: { + Recipient: { + orderBy: { + id: 'asc', + }, + }, + documentData: true, + }, + }); - await prisma.field.createMany({ - data: template.Field.map((field) => { - const recipient = template.Recipient.find((recipient) => recipient.id === field.recipientId); + let fieldsToCreate: Omit[] = []; - const documentRecipient = document.Recipient.find((doc) => doc.email === recipient?.email); + Object.values(finalRecipients).forEach(({ email, fields }) => { + const recipient = document.Recipient.find((recipient) => recipient.email === email); - if (!documentRecipient) { + if (!recipient) { throw new Error('Recipient not found.'); } - return { - type: field.type, - page: field.page, - positionX: field.positionX, - positionY: field.positionY, - width: field.width, - height: field.height, - customText: field.customText, - inserted: field.inserted, + fieldsToCreate = fieldsToCreate.concat( + fields.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: fieldsToCreate, + }); + + await tx.documentAuditLog.create({ + data: createDocumentAuditLogData({ + type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED, documentId: document.id, - recipientId: documentRecipient.id, - }; - }), - }); - - if (recipients && recipients.length > 0) { - document.Recipient = await Promise.all( - recipients.map(async (recipient, index) => { - const existingRecipient = document.Recipient.at(index); - - return await prisma.recipient.upsert({ - where: { - documentId_email: { - documentId: document.id, - email: existingRecipient?.email ?? recipient.email, - }, - }, - update: { - name: recipient.name, - email: recipient.email, - role: recipient.role, - }, - create: { - documentId: document.id, - email: recipient.email, - name: recipient.name, - role: recipient.role, - token: nanoid(), - }, - }); + user, + requestMetadata, + data: { + title: document.title, + }, }), - ); - } + }); - return document; + await triggerWebhook({ + event: WebhookTriggerEvents.DOCUMENT_CREATED, + data: document, + userId, + teamId, + }); + + return document; + }); }; diff --git a/packages/trpc/server/template-router/router.ts b/packages/trpc/server/template-router/router.ts index 4ed567b2b..3cca69548 100644 --- a/packages/trpc/server/template-router/router.ts +++ b/packages/trpc/server/template-router/router.ts @@ -1,10 +1,14 @@ import { TRPCError } from '@trpc/server'; import { getServerLimits } from '@documenso/ee/server-only/limits/server'; +import { AppError } from '@documenso/lib/errors/app-error'; +import { sendDocument } from '@documenso/lib/server-only/document/send-document'; import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template'; import { createTemplate } from '@documenso/lib/server-only/template/create-template'; import { deleteTemplate } from '@documenso/lib/server-only/template/delete-template'; import { duplicateTemplate } from '@documenso/lib/server-only/template/duplicate-template'; +import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; +import type { Document } from '@documenso/prisma/client'; import { authenticatedProcedure, router } from '../trpc'; import { @@ -49,19 +53,34 @@ export const templateRouter = router({ throw new Error('You have reached your document limit.'); } - return await createDocumentFromTemplate({ + const requestMetadata = extractNextApiRequestMetadata(ctx.req); + + let document: Document = await createDocumentFromTemplate({ templateId, teamId, userId: ctx.user.id, recipients: input.recipients, + requestMetadata, }); + + if (input.sendDocument) { + document = await sendDocument({ + documentId: document.id, + userId: ctx.user.id, + teamId, + requestMetadata, + }).catch((err) => { + console.error(err); + + throw new AppError('DOCUMENT_SEND_FAILED'); + }); + } + + return document; } catch (err) { console.error(err); - throw new TRPCError({ - code: 'BAD_REQUEST', - message: 'We were unable to create this document. Please try again later.', - }); + throw AppError.parseErrorToTRPCError(err); } }), diff --git a/packages/trpc/server/template-router/schema.ts b/packages/trpc/server/template-router/schema.ts index 3f16d7b39..ce1489ac3 100644 --- a/packages/trpc/server/template-router/schema.ts +++ b/packages/trpc/server/template-router/schema.ts @@ -1,7 +1,5 @@ import { z } from 'zod'; -import { RecipientRole } from '@documenso/prisma/client'; - export const ZCreateTemplateMutationSchema = z.object({ title: z.string().min(1).trim(), teamId: z.number().optional(), @@ -14,12 +12,16 @@ export const ZCreateDocumentFromTemplateMutationSchema = z.object({ recipients: z .array( z.object({ + id: z.number(), email: z.string().email(), - name: z.string(), - role: z.nativeEnum(RecipientRole), + name: z.string().optional(), }), ) - .optional(), + .refine((recipients) => { + const emails = recipients.map((signer) => signer.email); + return new Set(emails).size === emails.length; + }, 'Recipients must have unique emails'), + sendDocument: z.boolean().optional(), }); export const ZDuplicateTemplateMutationSchema = z.object({ diff --git a/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx b/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx index d285fbe44..cd48158c4 100644 --- a/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx +++ b/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx @@ -103,6 +103,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({ appendSigner({ formId: nanoid(12), name: `Recipient ${placeholderRecipientCount}`, + // Update TEMPLATE_RECIPIENT_PLACEHOLDER_REGEX if this is ever changed. email: `recipient.${placeholderRecipientCount}@documenso.com`, role: RecipientRole.SIGNER, });