diff --git a/apps/remix/app/components/dialogs/envelope-save-as-template-dialog.tsx b/apps/remix/app/components/dialogs/envelope-save-as-template-dialog.tsx new file mode 100644 index 000000000..a23549f16 --- /dev/null +++ b/apps/remix/app/components/dialogs/envelope-save-as-template-dialog.tsx @@ -0,0 +1,174 @@ +import { useState } from 'react'; + +import { Trans, useLingui } from '@lingui/react/macro'; +import { Controller, useForm } from 'react-hook-form'; +import { useNavigate } from 'react-router'; + +import { formatTemplatesPath } from '@documenso/lib/utils/teams'; +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { Checkbox } from '@documenso/ui/primitives/checkbox'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { Label } from '@documenso/ui/primitives/label'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { useCurrentTeam } from '~/providers/team'; + +type EnvelopeSaveAsTemplateDialogProps = { + envelopeId: string; + trigger?: React.ReactNode; +}; + +export const EnvelopeSaveAsTemplateDialog = ({ + envelopeId, + trigger, +}: EnvelopeSaveAsTemplateDialogProps) => { + const navigate = useNavigate(); + + const [open, setOpen] = useState(false); + + const { toast } = useToast(); + const { t } = useLingui(); + + const team = useCurrentTeam(); + + const templatesPath = formatTemplatesPath(team.url); + + const form = useForm({ + defaultValues: { + includeRecipients: true, + includeFields: true, + }, + }); + + const includeRecipients = form.watch('includeRecipients'); + + const { mutateAsync: saveAsTemplate, isPending } = trpc.envelope.saveAsTemplate.useMutation({ + onSuccess: async ({ id }) => { + toast({ + title: t`Template Created`, + description: t`Your document has been saved as a template.`, + duration: 5000, + }); + + await navigate(`${templatesPath}/${id}/edit`); + setOpen(false); + }, + }); + + const onSubmit = async () => { + const { includeRecipients, includeFields } = form.getValues(); + + try { + await saveAsTemplate({ + envelopeId, + includeRecipients, + includeFields: includeRecipients && includeFields, + }); + } catch { + toast({ + title: t`Something went wrong`, + description: t`This document could not be saved as a template at this time. Please try again.`, + variant: 'destructive', + duration: 7500, + }); + } + }; + + return ( + { + if (isPending) { + return; + } + + setOpen(value); + + if (!value) { + form.reset(); + } + }} + > + {trigger && {trigger}} + + + + + Save as Template + + + Create a template from this document. + + + + + ( + + { + field.onChange(checked === true); + + if (!checked) { + form.setValue('includeFields', false); + } + }} + /> + + Include Recipients + + + )} + /> + + ( + + field.onChange(checked === true)} + /> + + Include Fields + + + )} + /> + + + + + + Cancel + + + + + Save as Template + + + + + ); +}; diff --git a/apps/remix/app/components/general/document/document-page-view-dropdown.tsx b/apps/remix/app/components/general/document/document-page-view-dropdown.tsx index 282740369..7393c2835 100644 --- a/apps/remix/app/components/general/document/document-page-view-dropdown.tsx +++ b/apps/remix/app/components/general/document/document-page-view-dropdown.tsx @@ -1,12 +1,12 @@ import { useState } from 'react'; -import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; import { DocumentStatus } from '@prisma/client'; import { Copy, Download, Edit, + FileOutputIcon, Loader, MoreHorizontal, ScrollTextIcon, @@ -28,12 +28,12 @@ import { DropdownMenuLabel, DropdownMenuTrigger, } from '@documenso/ui/primitives/dropdown-menu'; -import { useToast } from '@documenso/ui/primitives/use-toast'; import { DocumentDeleteDialog } from '~/components/dialogs/document-delete-dialog'; import { DocumentDuplicateDialog } from '~/components/dialogs/document-duplicate-dialog'; import { DocumentResendDialog } from '~/components/dialogs/document-resend-dialog'; import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog'; +import { EnvelopeSaveAsTemplateDialog } from '~/components/dialogs/envelope-save-as-template-dialog'; import { DocumentRecipientLinkCopyDialog } from '~/components/general/document/document-recipient-link-copy-dialog'; import { useCurrentTeam } from '~/providers/team'; @@ -43,8 +43,6 @@ export type DocumentPageViewDropdownProps = { export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownProps) => { const { user } = useSession(); - const { toast } = useToast(); - const { _ } = useLingui(); const navigate = useNavigate(); const team = useCurrentTeam(); @@ -68,8 +66,8 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP return ( - - + + @@ -113,6 +111,18 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP Duplicate + e.preventDefault()}> + + + Save as Template + + + } + /> + setDeleteDialogOpen(true)} disabled={isDeleted}> Delete diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor.tsx index 0792e0add..4da481595 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor.tsx @@ -10,6 +10,7 @@ import { CopyPlusIcon, DownloadCloudIcon, EyeIcon, + FileOutputIcon, LinkIcon, type LucideIcon, MousePointerIcon, @@ -35,6 +36,7 @@ import { EnvelopeDistributeDialog } from '~/components/dialogs/envelope-distribu import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog'; import { EnvelopeDuplicateDialog } from '~/components/dialogs/envelope-duplicate-dialog'; import { EnvelopeRedistributeDialog } from '~/components/dialogs/envelope-redistribute-dialog'; +import { EnvelopeSaveAsTemplateDialog } from '~/components/dialogs/envelope-save-as-template-dialog'; import { TemplateDirectLinkDialog } from '~/components/dialogs/template-direct-link-dialog'; import { EnvelopeEditorSettingsDialog } from '~/components/general/envelope-editor/envelope-editor-settings-dialog'; @@ -101,6 +103,7 @@ export const EnvelopeEditor = () => { allowDistributing, allowDirectLink, allowDuplication, + allowSaveAsTemplate, allowDownloadPDF, allowDeletion, }, @@ -466,6 +469,28 @@ export const EnvelopeEditor = () => { /> )} + {allowSaveAsTemplate && isDocument && ( + + + + {!minimizeLeftSidebar && ( + + Save as Template + + )} + + } + /> + )} + {allowDownloadPDF && ( - + @@ -140,6 +142,18 @@ export const DocumentsTableActionDropdown = ({ Duplicate + e.preventDefault()}> + + + Save as Template + + + } + /> + {onMoveDocument && canManageDocument && ( e.preventDefault()}> diff --git a/apps/remix/app/routes/embed+/playground.tsx b/apps/remix/app/routes/embed+/playground.tsx index 9903bb8d6..f9e20df6f 100644 --- a/apps/remix/app/routes/embed+/playground.tsx +++ b/apps/remix/app/routes/embed+/playground.tsx @@ -70,6 +70,7 @@ export default function EmbedPlaygroundPage() { allowDistributing: false, allowDirectLink: false, allowDuplication: false, + allowSaveAsTemplate: false, allowDownloadPDF: false, allowDeletion: false, }); diff --git a/packages/app-tests/e2e/envelope-editor-v2/envelope-save-as-template.spec.ts b/packages/app-tests/e2e/envelope-editor-v2/envelope-save-as-template.spec.ts new file mode 100644 index 000000000..30ba7e473 --- /dev/null +++ b/packages/app-tests/e2e/envelope-editor-v2/envelope-save-as-template.spec.ts @@ -0,0 +1,495 @@ +import { type Page, expect, test } from '@playwright/test'; +import { EnvelopeType, FieldType, RecipientRole } from '@prisma/client'; +import fs from 'node:fs'; +import path from 'node:path'; + +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token'; +import { prisma } from '@documenso/prisma'; +import { seedUser } from '@documenso/prisma/seed/users'; +import type { + TCreateEnvelopePayload, + TCreateEnvelopeResponse, +} from '@documenso/trpc/server/envelope-router/create-envelope.types'; +import type { TCreateEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/envelope-recipients/create-envelope-recipients.types'; +import type { TGetEnvelopeResponse } from '@documenso/trpc/server/envelope-router/get-envelope.types'; + +import { apiSignin } from '../fixtures/authentication'; +import { + clickAddMyselfButton, + clickEnvelopeEditorStep, + getRecipientEmailInputs, + openDocumentEnvelopeEditor, +} from '../fixtures/envelope-editor'; +import { expectToastTextToBeVisible } from '../fixtures/generic'; + +const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL(); +const V2_API_BASE_URL = `${WEBAPP_BASE_URL}/api/v2-beta`; + +const examplePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../assets/example.pdf')); + +/** + * Place a field on the PDF canvas in the envelope editor. + */ +const placeFieldOnPdf = async ( + root: Page, + fieldName: 'Signature' | 'Text', + position: { x: number; y: number }, +) => { + await root.getByRole('button', { name: fieldName, exact: true }).click(); + + const canvas = root.locator('.konva-container canvas').first(); + await expect(canvas).toBeVisible(); + await canvas.click({ position }); +}; + +/** + * Create a V2 document envelope via the API with a single SIGNER recipient + * and a SIGNATURE field, suitable for testing save-as-template with data. + * + * Returns the envelope ID, user, team, and recipient email. + */ +const createDocumentWithRecipientAndField = async () => { + const { user, team } = await seedUser(); + + const { token } = await createApiToken({ + userId: user.id, + teamId: team.id, + tokenName: `e2e-save-as-template-${Date.now()}`, + expiresIn: null, + }); + + const recipientEmail = `save-template-${Date.now()}@test.documenso.com`; + + // 1. Create envelope with a PDF. + const payload = { + type: EnvelopeType.DOCUMENT, + title: `E2E Save as Template ${Date.now()}`, + } satisfies TCreateEnvelopePayload; + + const formData = new FormData(); + formData.append('payload', JSON.stringify(payload)); + formData.append( + 'files', + new File([examplePdfBuffer], 'example.pdf', { type: 'application/pdf' }), + ); + + const createRes = await fetch(`${V2_API_BASE_URL}/envelope/create`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + body: formData, + }); + + expect(createRes.ok).toBeTruthy(); + const createResponse = (await createRes.json()) as TCreateEnvelopeResponse; + + // 2. Create a SIGNER recipient. + const createRecipientsRequest: TCreateEnvelopeRecipientsRequest = { + envelopeId: createResponse.id, + data: [ + { + email: recipientEmail, + name: 'Template Test Signer', + role: RecipientRole.SIGNER, + accessAuth: [], + actionAuth: [], + }, + ], + }; + + const recipientsRes = await fetch(`${V2_API_BASE_URL}/envelope/recipient/create-many`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(createRecipientsRequest), + }); + + expect(recipientsRes.ok).toBeTruthy(); + const recipientsResponse = await recipientsRes.json(); + const recipients = recipientsResponse.data; + + // 3. Get envelope to find the envelope item ID. + const getRes = await fetch(`${V2_API_BASE_URL}/envelope/${createResponse.id}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + + const envelope = (await getRes.json()) as TGetEnvelopeResponse; + const envelopeItem = envelope.envelopeItems[0]; + + // 4. Create a SIGNATURE field for the recipient. + const createFieldsRequest = { + envelopeId: createResponse.id, + data: [ + { + recipientId: recipients[0].id, + envelopeItemId: envelopeItem.id, + type: FieldType.SIGNATURE, + page: 1, + positionX: 100, + positionY: 100, + width: 50, + height: 50, + }, + ], + }; + + const fieldsRes = await fetch(`${V2_API_BASE_URL}/envelope/field/create-many`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(createFieldsRequest), + }); + + expect(fieldsRes.ok).toBeTruthy(); + + return { + user, + team, + envelopeId: createResponse.id, + recipientEmail, + }; +}; + +test.describe('document editor', () => { + test('save document as template from editor sidebar', async ({ page }) => { + const surface = await openDocumentEnvelopeEditor(page); + + // Add the current user as a recipient via the UI. + await clickAddMyselfButton(surface.root); + await expect(getRecipientEmailInputs(surface.root).first()).toHaveValue(surface.userEmail); + + // Navigate to the add fields step and place a signature field. + await clickEnvelopeEditorStep(surface.root, 'addFields'); + await expect(surface.root.locator('.konva-container canvas').first()).toBeVisible(); + await placeFieldOnPdf(surface.root, 'Signature', { x: 120, y: 140 }); + + // Navigate back to the upload step so the sidebar actions are available. + await clickEnvelopeEditorStep(surface.root, 'upload'); + + // Click the "Save as Template" sidebar action. + await page.locator('button[title="Save as Template"]').click(); + + // The save as template dialog should appear. + await expect(page.getByRole('heading', { name: 'Save as Template' })).toBeVisible(); + + // Both checkboxes should be checked by default. + await expect(page.locator('#envelopeIncludeRecipients')).toBeChecked(); + await expect(page.locator('#envelopeIncludeFields')).toBeChecked(); + + // Click "Save as Template". + await page.getByRole('button', { name: 'Save as Template' }).click(); + + // Assert toast appears. + await expectToastTextToBeVisible(page, 'Template Created'); + + // The page should have navigated to the new template's edit page. + await expect(page).toHaveURL(/\/templates\/.*\/edit/); + + // Verify the new template envelope was created in the database with correct type and secondaryId. + const templateEnvelopes = await prisma.envelope.findMany({ + where: { + userId: surface.userId, + teamId: surface.teamId, + type: 'TEMPLATE', + }, + include: { + recipients: { + include: { + fields: true, + }, + }, + }, + }); + + expect(templateEnvelopes.length).toBeGreaterThanOrEqual(1); + + const createdTemplate = templateEnvelopes.find((e) => e.title.includes('(copy)')); + expect(createdTemplate).toBeDefined(); + + // CRITICAL: Verify the secondaryId uses the template_ prefix, not document_. + // This confirms incrementTemplateId was called, not incrementDocumentId. + expect(createdTemplate!.secondaryId).toMatch(/^template_\d+$/); + expect(createdTemplate!.type).toBe(EnvelopeType.TEMPLATE); + + // Verify recipients were included. + expect(createdTemplate!.recipients.length).toBe(1); + expect(createdTemplate!.recipients[0].email).toBe(surface.userEmail); + + // Verify fields were included. + expect(createdTemplate!.recipients[0].fields.length).toBe(1); + expect(createdTemplate!.recipients[0].fields[0].type).toBe(FieldType.SIGNATURE); + }); + + test('save document as template without recipients', async ({ page }) => { + const { user, team, envelopeId, recipientEmail } = await createDocumentWithRecipientAndField(); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/t/${team.url}/documents/${envelopeId}/edit`, + }); + + await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible(); + + // Click the "Save as Template" sidebar action. + await page.locator('button[title="Save as Template"]').click(); + + // The dialog should appear. + await expect(page.getByRole('heading', { name: 'Save as Template' })).toBeVisible(); + + // Uncheck "Include Recipients" - "Include Fields" should auto-disable. + await page.locator('#envelopeIncludeRecipients').click(); + await expect(page.locator('#envelopeIncludeRecipients')).not.toBeChecked(); + await expect(page.locator('#envelopeIncludeFields')).not.toBeChecked(); + await expect(page.locator('#envelopeIncludeFields')).toBeDisabled(); + + // Click "Save as Template". + await page.getByRole('button', { name: 'Save as Template' }).click(); + + // Assert toast appears. + await expectToastTextToBeVisible(page, 'Template Created'); + + // The page should have navigated to the new template's edit page. + await expect(page).toHaveURL(/\/templates\/.*\/edit/); + + // Verify the template was created without recipients. + const templateEnvelopes = await prisma.envelope.findMany({ + where: { + userId: user.id, + teamId: team.id, + type: 'TEMPLATE', + }, + include: { + recipients: true, + }, + }); + + const createdTemplate = templateEnvelopes.find((e) => e.title.includes('(copy)')); + expect(createdTemplate).toBeDefined(); + + // CRITICAL: Verify the secondaryId uses the template_ prefix. + expect(createdTemplate!.secondaryId).toMatch(/^template_\d+$/); + + // Verify no recipients were included. + expect(createdTemplate!.recipients.length).toBe(0); + }); + + test('save document as template without fields but with recipients', async ({ page }) => { + const { user, team, envelopeId, recipientEmail } = await createDocumentWithRecipientAndField(); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/t/${team.url}/documents/${envelopeId}/edit`, + }); + + await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible(); + + // Click the "Save as Template" sidebar action. + await page.locator('button[title="Save as Template"]').click(); + + // The dialog should appear. + await expect(page.getByRole('heading', { name: 'Save as Template' })).toBeVisible(); + + // Uncheck "Include Fields" but keep "Include Recipients". + await page.locator('#envelopeIncludeFields').click(); + await expect(page.locator('#envelopeIncludeRecipients')).toBeChecked(); + await expect(page.locator('#envelopeIncludeFields')).not.toBeChecked(); + + // Click "Save as Template". + await page.getByRole('button', { name: 'Save as Template' }).click(); + + // Assert toast appears. + await expectToastTextToBeVisible(page, 'Template Created'); + + // The page should have navigated to the new template's edit page. + await expect(page).toHaveURL(/\/templates\/.*\/edit/); + + // Verify the template was created with recipients but no fields. + const templateEnvelopes = await prisma.envelope.findMany({ + where: { + userId: user.id, + teamId: team.id, + type: 'TEMPLATE', + }, + include: { + recipients: { + include: { + fields: true, + }, + }, + }, + }); + + const createdTemplate = templateEnvelopes.find((e) => e.title.includes('(copy)')); + expect(createdTemplate).toBeDefined(); + + // CRITICAL: Verify the secondaryId uses the template_ prefix. + expect(createdTemplate!.secondaryId).toMatch(/^template_\d+$/); + + // Verify recipients were included. + expect(createdTemplate!.recipients.length).toBe(1); + expect(createdTemplate!.recipients[0].email).toBe(recipientEmail); + + // Verify no fields were included. + expect(createdTemplate!.recipients[0].fields.length).toBe(0); + }); +}); + +test.describe('documents table', () => { + test('save as template from document row dropdown', async ({ page }) => { + const { user, team, envelopeId, recipientEmail } = await createDocumentWithRecipientAndField(); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/t/${team.url}/documents`, + }); + + // Wait for the documents table to load. + await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible(); + + // Click the actions dropdown for the document row. + await page.getByTestId('document-table-action-btn').first().click(); + + // Click "Save as Template" in the dropdown. + await page.getByRole('menuitem', { name: 'Save as Template' }).click(); + + // The dialog should appear. + await expect(page.getByRole('heading', { name: 'Save as Template' })).toBeVisible(); + + // Click "Save as Template". + await page.getByRole('button', { name: 'Save as Template' }).click(); + + // Assert toast appears. + await expectToastTextToBeVisible(page, 'Template Created'); + + // The page should have navigated to the new template's edit page. + await expect(page).toHaveURL(/\/templates\/.*\/edit/); + + // Verify the template was created with correct type and secondaryId. + const templateEnvelopes = await prisma.envelope.findMany({ + where: { + userId: user.id, + teamId: team.id, + type: 'TEMPLATE', + }, + include: { + recipients: { + include: { + fields: true, + }, + }, + }, + }); + + const createdTemplate = templateEnvelopes.find((e) => e.title.includes('(copy)')); + expect(createdTemplate).toBeDefined(); + + // CRITICAL: Verify the secondaryId uses the template_ prefix. + expect(createdTemplate!.secondaryId).toMatch(/^template_\d+$/); + expect(createdTemplate!.type).toBe(EnvelopeType.TEMPLATE); + + // Verify recipients and fields were included (defaults are both checked). + expect(createdTemplate!.recipients.length).toBe(1); + expect(createdTemplate!.recipients[0].fields.length).toBe(1); + }); +}); + +test.describe('document index page', () => { + test('save as template from document page dropdown', async ({ page }) => { + const { user, team, envelopeId, recipientEmail } = await createDocumentWithRecipientAndField(); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/t/${team.url}/documents/${envelopeId}`, + }); + + // Wait for the document page to load. + await expect(page.getByRole('heading', { name: /E2E Save as Template/ })).toBeVisible(); + + // Click the more actions dropdown trigger. + await page.getByTestId('document-page-view-action-btn').click(); + + // Click "Save as Template" in the dropdown. + await page.getByRole('menuitem', { name: 'Save as Template' }).click(); + + // The dialog should appear. + await expect(page.getByRole('heading', { name: 'Save as Template' })).toBeVisible(); + + // Click "Save as Template". + await page.getByRole('button', { name: 'Save as Template' }).click(); + + // Assert toast appears. + await expectToastTextToBeVisible(page, 'Template Created'); + + // The page should have navigated to the new template's edit page. + await expect(page).toHaveURL(/\/templates\/.*\/edit/); + + // Verify the template was created with correct type and secondaryId. + const templateEnvelopes = await prisma.envelope.findMany({ + where: { + userId: user.id, + teamId: team.id, + type: 'TEMPLATE', + }, + }); + + const createdTemplate = templateEnvelopes.find((e) => e.title.includes('(copy)')); + expect(createdTemplate).toBeDefined(); + + // CRITICAL: Verify the secondaryId uses the template_ prefix. + expect(createdTemplate!.secondaryId).toMatch(/^template_\d+$/); + expect(createdTemplate!.type).toBe(EnvelopeType.TEMPLATE); + }); +}); + +test.describe('legacy ID correctness', () => { + test('save as template uses template counter, not document counter', async ({ page }) => { + // Record the current counter values before the operation. + const [documentCounterBefore, templateCounterBefore] = await Promise.all([ + prisma.counter.findUnique({ where: { id: 'document' } }), + prisma.counter.findUnique({ where: { id: 'template' } }), + ]); + + const surface = await openDocumentEnvelopeEditor(page); + + // Click the "Save as Template" sidebar action. + await page.locator('button[title="Save as Template"]').click(); + await expect(page.getByRole('heading', { name: 'Save as Template' })).toBeVisible(); + + // Click "Save as Template". + await page.getByRole('button', { name: 'Save as Template' }).click(); + await expectToastTextToBeVisible(page, 'Template Created'); + await expect(page).toHaveURL(/\/templates\/.*\/edit/); + + // Record the counter values after the operation. + const [documentCounterAfter, templateCounterAfter] = await Promise.all([ + prisma.counter.findUnique({ where: { id: 'document' } }), + prisma.counter.findUnique({ where: { id: 'template' } }), + ]); + + // The template counter MUST have incremented (at least once - could be more due to + // the seedBlankDocument call in openDocumentEnvelopeEditor seeding other templates). + expect(templateCounterAfter!.value).toBeGreaterThan(templateCounterBefore!.value); + + // Verify the created template's secondaryId matches the template counter. + const createdTemplate = await prisma.envelope.findFirst({ + where: { + userId: surface.userId, + teamId: surface.teamId, + type: 'TEMPLATE', + }, + orderBy: { createdAt: 'desc' }, + }); + + expect(createdTemplate).not.toBeNull(); + expect(createdTemplate!.secondaryId).toBe(`template_${templateCounterAfter!.value}`); + expect(createdTemplate!.secondaryId).not.toMatch(/^document_/); + }); +}); diff --git a/packages/lib/server-only/envelope/duplicate-envelope.ts b/packages/lib/server-only/envelope/duplicate-envelope.ts index 3b3398ba2..4345a6876 100644 --- a/packages/lib/server-only/envelope/duplicate-envelope.ts +++ b/packages/lib/server-only/envelope/duplicate-envelope.ts @@ -19,9 +19,25 @@ export interface DuplicateEnvelopeOptions { id: EnvelopeIdOptions; userId: number; teamId: number; + overrides?: { + duplicateAsTemplate?: boolean; + includeRecipients?: boolean; + includeFields?: boolean; + }; } -export const duplicateEnvelope = async ({ id, userId, teamId }: DuplicateEnvelopeOptions) => { +export const duplicateEnvelope = async ({ + id, + userId, + teamId, + overrides, +}: DuplicateEnvelopeOptions) => { + const { + duplicateAsTemplate = false, + includeRecipients = true, + includeFields = true, + } = overrides ?? {}; + const { envelopeWhereInput } = await getEnvelopeWhereInput({ id, type: null, @@ -72,8 +88,16 @@ export const duplicateEnvelope = async ({ id, userId, teamId }: DuplicateEnvelop }); } + if (duplicateAsTemplate && envelope.type !== EnvelopeType.DOCUMENT) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: 'Only documents can be saved as templates', + }); + } + + const targetType = duplicateAsTemplate ? EnvelopeType.TEMPLATE : envelope.type; + const [{ legacyNumberId, secondaryId }, createdDocumentMeta] = await Promise.all([ - envelope.type === EnvelopeType.DOCUMENT + targetType === EnvelopeType.DOCUMENT ? incrementDocumentId().then(({ documentId, formattedDocumentId }) => ({ legacyNumberId: documentId, secondaryId: formattedDocumentId, @@ -99,7 +123,7 @@ export const duplicateEnvelope = async ({ id, userId, teamId }: DuplicateEnvelop data: { id: prefixedId('envelope'), secondaryId, - type: envelope.type, + type: targetType, internalVersion: envelope.internalVersion, userId, teamId, @@ -111,7 +135,7 @@ export const duplicateEnvelope = async ({ id, userId, teamId }: DuplicateEnvelop publicTitle: envelope.publicTitle ?? undefined, publicDescription: envelope.publicDescription ?? undefined, source: - envelope.type === EnvelopeType.DOCUMENT ? DocumentSource.DOCUMENT : DocumentSource.TEMPLATE, + targetType === EnvelopeType.DOCUMENT ? DocumentSource.DOCUMENT : DocumentSource.TEMPLATE, }, include: { recipients: true, @@ -148,38 +172,42 @@ export const duplicateEnvelope = async ({ id, userId, teamId }: DuplicateEnvelop }), ); - await pMap( - envelope.recipients, - async (recipient) => - prisma.recipient.create({ - data: { - envelopeId: duplicatedEnvelope.id, - email: recipient.email, - name: recipient.name, - role: recipient.role, - signingOrder: recipient.signingOrder, - token: nanoid(), - fields: { - createMany: { - data: recipient.fields.map((field) => ({ - envelopeId: duplicatedEnvelope.id, - envelopeItemId: oldEnvelopeItemToNewEnvelopeItemIdMap[field.envelopeItemId], - type: field.type, - page: field.page, - positionX: field.positionX, - positionY: field.positionY, - width: field.width, - height: field.height, - customText: '', - inserted: false, - fieldMeta: field.fieldMeta as PrismaJson.FieldMeta, - })), - }, + if (includeRecipients) { + await pMap( + envelope.recipients, + async (recipient) => + prisma.recipient.create({ + data: { + envelopeId: duplicatedEnvelope.id, + email: recipient.email, + name: recipient.name, + role: recipient.role, + signingOrder: recipient.signingOrder, + token: nanoid(), + fields: includeFields + ? { + createMany: { + data: recipient.fields.map((field) => ({ + envelopeId: duplicatedEnvelope.id, + envelopeItemId: oldEnvelopeItemToNewEnvelopeItemIdMap[field.envelopeItemId], + type: field.type, + page: field.page, + positionX: field.positionX, + positionY: field.positionY, + width: field.width, + height: field.height, + customText: '', + inserted: false, + fieldMeta: field.fieldMeta as PrismaJson.FieldMeta, + })), + }, + } + : undefined, }, - }, - }), - { concurrency: 5 }, - ); + }), + { concurrency: 5 }, + ); + } if (duplicatedEnvelope.type === EnvelopeType.DOCUMENT) { const refetchedEnvelope = await prisma.envelope.findFirstOrThrow({ @@ -204,7 +232,7 @@ export const duplicateEnvelope = async ({ id, userId, teamId }: DuplicateEnvelop id: duplicatedEnvelope.id, envelope: duplicatedEnvelope, legacyId: { - type: envelope.type, + type: duplicatedEnvelope.type, id: legacyNumberId, }, }; diff --git a/packages/lib/types/envelope-editor.ts b/packages/lib/types/envelope-editor.ts index 66a355391..02cfa4abd 100644 --- a/packages/lib/types/envelope-editor.ts +++ b/packages/lib/types/envelope-editor.ts @@ -56,6 +56,7 @@ export const ZEnvelopeEditorSettingsSchema = z.object({ allowDistributing: z.boolean(), allowDirectLink: z.boolean(), allowDuplication: z.boolean(), + allowSaveAsTemplate: z.boolean(), allowDownloadPDF: z.boolean(), allowDeletion: z.boolean(), }), @@ -129,6 +130,7 @@ export const DEFAULT_EDITOR_CONFIG: EnvelopeEditorConfig = { allowDistributing: true, allowDirectLink: true, allowDuplication: true, + allowSaveAsTemplate: true, allowDownloadPDF: true, allowDeletion: true, }, @@ -186,6 +188,7 @@ export const DEFAULT_EMBEDDED_EDITOR_CONFIG = { allowDistributing: false, // These are not supported for embeds, and are directly excluded in the embedded repo. allowDirectLink: false, // These are not supported for embeds, and are directly excluded in the embedded repo. allowDuplication: false, // These are not supported for embeds, and are directly excluded in the embedded repo. + allowSaveAsTemplate: false, // These are not supported for embeds, and are directly excluded in the embedded repo. allowDownloadPDF: false, // These are not supported for embeds, and are directly excluded in the embedded repo. allowDeletion: false, // These are not supported for embeds, and are directly excluded in the embedded repo. }, diff --git a/packages/lib/utils/embed-config.ts b/packages/lib/utils/embed-config.ts index 132d12433..d3f44b72e 100644 --- a/packages/lib/utils/embed-config.ts +++ b/packages/lib/utils/embed-config.ts @@ -88,6 +88,9 @@ export const buildEmbeddedFeatures = ( allowDuplication: features.actions?.allowDuplication ?? DEFAULT_EMBEDDED_EDITOR_CONFIG.actions.allowDuplication, + allowSaveAsTemplate: + features.actions?.allowSaveAsTemplate ?? + DEFAULT_EMBEDDED_EDITOR_CONFIG.actions.allowSaveAsTemplate, allowDownloadPDF: features.actions?.allowDownloadPDF ?? DEFAULT_EMBEDDED_EDITOR_CONFIG.actions.allowDownloadPDF, diff --git a/packages/trpc/server/envelope-router/router.ts b/packages/trpc/server/envelope-router/router.ts index 6b52196eb..faa20d03f 100644 --- a/packages/trpc/server/envelope-router/router.ts +++ b/packages/trpc/server/envelope-router/router.ts @@ -29,6 +29,7 @@ import { getEnvelopeItemsByTokenRoute } from './get-envelope-items-by-token'; import { getEnvelopesByIdsRoute } from './get-envelopes-by-ids'; import { redistributeEnvelopeRoute } from './redistribute-envelope'; import { replaceEnvelopeItemPdfRoute } from './replace-envelope-item-pdf'; +import { saveAsTemplateRoute } from './save-as-template'; import { setEnvelopeFieldsRoute } from './set-envelope-fields'; import { setEnvelopeRecipientsRoute } from './set-envelope-recipients'; import { signEnvelopeFieldRoute } from './sign-envelope-field'; @@ -91,6 +92,7 @@ export const envelopeRouter = router({ update: updateEnvelopeRoute, delete: deleteEnvelopeRoute, duplicate: duplicateEnvelopeRoute, + saveAsTemplate: saveAsTemplateRoute, distribute: distributeEnvelopeRoute, redistribute: redistributeEnvelopeRoute, signingStatus: signingStatusEnvelopeRoute, diff --git a/packages/trpc/server/envelope-router/save-as-template.ts b/packages/trpc/server/envelope-router/save-as-template.ts new file mode 100644 index 000000000..65f323640 --- /dev/null +++ b/packages/trpc/server/envelope-router/save-as-template.ts @@ -0,0 +1,39 @@ +import { duplicateEnvelope } from '@documenso/lib/server-only/envelope/duplicate-envelope'; + +import { authenticatedProcedure } from '../trpc'; +import { + ZSaveAsTemplateRequestSchema, + ZSaveAsTemplateResponseSchema, +} from './save-as-template.types'; + +export const saveAsTemplateRoute = authenticatedProcedure + .input(ZSaveAsTemplateRequestSchema) + .output(ZSaveAsTemplateResponseSchema) + .mutation(async ({ input, ctx }) => { + const { envelopeId, includeRecipients, includeFields } = input; + const { teamId } = ctx; + + ctx.logger.info({ + input: { + envelopeId, + }, + }); + + const result = await duplicateEnvelope({ + userId: ctx.user.id, + teamId, + id: { + type: 'envelopeId', + id: envelopeId, + }, + overrides: { + duplicateAsTemplate: true, + includeRecipients, + includeFields, + }, + }); + + return { + id: result.id, + }; + }); diff --git a/packages/trpc/server/envelope-router/save-as-template.types.ts b/packages/trpc/server/envelope-router/save-as-template.types.ts new file mode 100644 index 000000000..01c72f0bf --- /dev/null +++ b/packages/trpc/server/envelope-router/save-as-template.types.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; + +export const ZSaveAsTemplateRequestSchema = z.object({ + envelopeId: z.string().min(1).describe('The ID of the envelope to save as a template.'), + includeRecipients: z.boolean(), + includeFields: z.boolean(), +}); + +export const ZSaveAsTemplateResponseSchema = z.object({ + id: z.string().describe('The ID of the newly created template envelope.'), +}); + +export type TSaveAsTemplateRequest = z.infer; +export type TSaveAsTemplateResponse = z.infer;