From cce0cdfbe26faaaac94aca93ea835e589b6e5e58 Mon Sep 17 00:00:00 2001 From: Adithya Krishna Date: Thu, 18 Apr 2024 17:47:50 +0530 Subject: [PATCH] feat: add template meta for templates Signed-off-by: Adithya Krishna --- .../templates/[id]/edit-template.tsx | 61 ++- .../templates/[id]/template-page-view.tsx | 3 +- .../document/send-completed-email.ts | 2 +- .../upsert-template-document-meta.ts | 50 +++ .../template/create-document-from-template.ts | 13 + .../template/duplicate-template.ts | 1 + .../server-only/template/find-templates.ts | 1 + .../template/get-template-by-id.ts | 1 + .../migration.sql | 19 + packages/prisma/schema.prisma | 13 + .../template-flow/add-template-settings.tsx | 369 ++++++++++++++++++ .../add-template-settings.types.ts | 42 ++ 12 files changed, 569 insertions(+), 6 deletions(-) create mode 100644 packages/lib/server-only/template-document-meta/upsert-template-document-meta.ts create mode 100644 packages/prisma/migrations/20240416212742_create_template_document_meta/migration.sql create mode 100644 packages/ui/primitives/template-flow/add-template-settings.tsx create mode 100644 packages/ui/primitives/template-flow/add-template-settings.types.ts diff --git a/apps/web/src/app/(dashboard)/templates/[id]/edit-template.tsx b/apps/web/src/app/(dashboard)/templates/[id]/edit-template.tsx index f8c7f9a43..d77fdd552 100644 --- a/apps/web/src/app/(dashboard)/templates/[id]/edit-template.tsx +++ b/apps/web/src/app/(dashboard)/templates/[id]/edit-template.tsx @@ -1,9 +1,15 @@ +/* eslint-disable unused-imports/no-unused-imports */ + +/* eslint-disable @typescript-eslint/consistent-type-imports */ + +/* eslint-disable unused-imports/no-unused-vars */ 'use client'; import { useState } from 'react'; import { useRouter } from 'next/navigation'; +import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; import type { DocumentData, Field, Recipient, Template, User } from '@documenso/prisma/client'; import { trpc } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; @@ -19,8 +25,14 @@ import { AddTemplateFieldsFormPartial } from '@documenso/ui/primitives/template- import type { TAddTemplateFieldsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-fields.types'; import { AddTemplatePlaceholderRecipientsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-placeholder-recipients'; import type { TAddTemplatePlacholderRecipientsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-placeholder-recipients.types'; +import { AddTemplateSettingsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-settings'; +import { TTemplateSettingsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-settings.types'; import { useToast } from '@documenso/ui/primitives/use-toast'; +/* eslint-disable unused-imports/no-unused-imports */ +/* eslint-disable @typescript-eslint/consistent-type-imports */ +/* eslint-disable unused-imports/no-unused-vars */ + export type EditTemplateFormProps = { className?: string; user: User; @@ -31,8 +43,8 @@ export type EditTemplateFormProps = { templateRootPath: string; }; -type EditTemplateStep = 'signers' | 'fields'; -const EditTemplateSteps: EditTemplateStep[] = ['signers', 'fields']; +type EditTemplateStep = 'settings' | 'signers' | 'fields'; +const EditTemplateSteps: EditTemplateStep[] = ['settings', 'signers', 'fields']; export const EditTemplateForm = ({ className, @@ -49,15 +61,20 @@ export const EditTemplateForm = ({ const [step, setStep] = useState('signers'); const documentFlow: Record = { + settings: { + title: 'General', + description: 'Configure general settings for the document.', + stepIndex: 1, + }, signers: { title: 'Add Placeholders', description: 'Add all relevant placeholders for each recipient.', - stepIndex: 1, + stepIndex: 2, }, fields: { title: 'Add Fields', description: 'Add all relevant fields for each recipient.', - stepIndex: 2, + stepIndex: 3, }, }; @@ -87,6 +104,35 @@ export const EditTemplateForm = ({ } }; + const onAddTemplateSettingsFormSubmit = async (data: TTemplateSettingsFormSchema) => { + // try { + // const { timezone, dateFormat, redirectUrl } = data.meta; + // await setSettingsForDocument({ + // documentId: document.id, + // data: { + // title: data.title, + // globalAccessAuth: data.globalAccessAuth ?? null, + // globalActionAuth: data.globalActionAuth ?? null, + // }, + // meta: { + // timezone, + // dateFormat, + // redirectUrl, + // }, + // }); + // // Router refresh is here to clear the router cache for when navigating to /documents. + // router.refresh(); + // setStep('signers'); + // } catch (err) { + // console.error(err); + // toast({ + // title: 'Error', + // description: 'An error occurred while updating the document settings.', + // variant: 'destructive', + // }); + // } + }; + const onAddFieldsFormSubmit = async (data: TAddTemplateFieldsFormSchema) => { try { await addTemplateFields({ @@ -135,6 +181,13 @@ export const EditTemplateForm = ({ currentStep={currentDocumentFlow.stepIndex} setCurrentStep={(step) => setStep(EditTemplateSteps[step - 1])} > + diff --git a/packages/lib/server-only/document/send-completed-email.ts b/packages/lib/server-only/document/send-completed-email.ts index a397e47e7..f5cef426c 100644 --- a/packages/lib/server-only/document/send-completed-email.ts +++ b/packages/lib/server-only/document/send-completed-email.ts @@ -80,7 +80,7 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo text: render(template, { plainText: true }), attachments: [ { - filename: document.title, + filename: document.title.endsWith('.pdf') ? document.title : document.title + '.pdf', content: Buffer.from(completedDocument), }, ], diff --git a/packages/lib/server-only/template-document-meta/upsert-template-document-meta.ts b/packages/lib/server-only/template-document-meta/upsert-template-document-meta.ts new file mode 100644 index 000000000..7128a4220 --- /dev/null +++ b/packages/lib/server-only/template-document-meta/upsert-template-document-meta.ts @@ -0,0 +1,50 @@ +'use server'; + +import { prisma } from '@documenso/prisma'; + +export type CreateTemplateDocumentMetaOptions = { + templateId: number; + subject?: string; + message?: string; + timezone?: string; + password?: string; + dateFormat?: string; + redirectUrl?: string; +}; + +export const upsertTemplateDocumentMeta = async ({ + subject, + message, + timezone, + dateFormat, + templateId, + password, + redirectUrl, +}: CreateTemplateDocumentMetaOptions) => { + return await prisma.$transaction(async (tx) => { + const upsertedTemplateDocumentMeta = await tx.templateDocumentMeta.upsert({ + where: { + templateId, + }, + update: { + subject, + message, + timezone, + password, + dateFormat, + redirectUrl, + }, + create: { + templateId, + subject, + message, + timezone, + password, + dateFormat, + redirectUrl, + }, + }); + + return upsertedTemplateDocumentMeta; + }); +}; 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 8ae5fecaf..d223965a5 100644 --- a/packages/lib/server-only/template/create-document-from-template.ts +++ b/packages/lib/server-only/template/create-document-from-template.ts @@ -42,6 +42,7 @@ export const createDocumentFromTemplate = async ({ Recipient: true, Field: true, templateDocumentData: true, + templateDocumentMeta: true, }, }); @@ -57,12 +58,24 @@ export const createDocumentFromTemplate = async ({ }, }); + // const templateDocumentMeta = await prisma.documentMeta.create({ + // data: { + // subject: template.templateDocumentMeta.subject, + // message: template.templateDocumentMeta.message, + // timezone: template.templateDocumentMeta.timezone, + // password: template.templateDocumentMeta.password, + // dateFormat: template.templateDocumentMeta.dateFormat, + // redirectUrl: template.templateDocumentMeta.redirectUrl, + // }, + // }); + const document = await prisma.document.create({ data: { userId, teamId: template.teamId, title: template.title, documentDataId: documentData.id, + // documentMeta: templateDocumentMeta, Recipient: { create: template.Recipient.map((recipient) => ({ email: recipient.email, diff --git a/packages/lib/server-only/template/duplicate-template.ts b/packages/lib/server-only/template/duplicate-template.ts index 97b3f0a0b..71702f8a8 100644 --- a/packages/lib/server-only/template/duplicate-template.ts +++ b/packages/lib/server-only/template/duplicate-template.ts @@ -38,6 +38,7 @@ export const duplicateTemplate = async ({ Recipient: true, Field: true, templateDocumentData: true, + templateDocumentMeta: true, }, }); diff --git a/packages/lib/server-only/template/find-templates.ts b/packages/lib/server-only/template/find-templates.ts index 9252d32ea..23ead4333 100644 --- a/packages/lib/server-only/template/find-templates.ts +++ b/packages/lib/server-only/template/find-templates.ts @@ -37,6 +37,7 @@ export const findTemplates = async ({ where: whereFilter, include: { templateDocumentData: true, + templateDocumentMeta: true, team: { select: { id: true, diff --git a/packages/lib/server-only/template/get-template-by-id.ts b/packages/lib/server-only/template/get-template-by-id.ts index c4295c3c3..ba130f1ab 100644 --- a/packages/lib/server-only/template/get-template-by-id.ts +++ b/packages/lib/server-only/template/get-template-by-id.ts @@ -29,6 +29,7 @@ export const getTemplateById = async ({ id, userId }: GetTemplateByIdOptions) => where: whereFilter, include: { templateDocumentData: true, + templateDocumentMeta: true, }, }); }; diff --git a/packages/prisma/migrations/20240416212742_create_template_document_meta/migration.sql b/packages/prisma/migrations/20240416212742_create_template_document_meta/migration.sql new file mode 100644 index 000000000..c8561bf84 --- /dev/null +++ b/packages/prisma/migrations/20240416212742_create_template_document_meta/migration.sql @@ -0,0 +1,19 @@ +-- CreateTable +CREATE TABLE "TemplateDocumentMeta" ( + "id" TEXT NOT NULL, + "subject" TEXT, + "message" TEXT, + "timezone" TEXT DEFAULT 'Etc/UTC', + "password" TEXT, + "dateFormat" TEXT DEFAULT 'yyyy-MM-dd hh:mm a', + "templateId" INTEGER NOT NULL, + "redirectUrl" TEXT, + + CONSTRAINT "TemplateDocumentMeta_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "TemplateDocumentMeta_templateId_key" ON "TemplateDocumentMeta"("templateId"); + +-- AddForeignKey +ALTER TABLE "TemplateDocumentMeta" ADD CONSTRAINT "TemplateDocumentMeta_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "Template"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 35d429779..bd864fedf 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -324,6 +324,18 @@ model DocumentMeta { redirectUrl String? } +model TemplateDocumentMeta { + id String @id @default(cuid()) + subject String? + message String? + timezone String? @default("Etc/UTC") @db.Text + password String? + dateFormat String? @default("yyyy-MM-dd hh:mm a") @db.Text + templateId Int @unique + template Template @relation(fields: [templateId], references: [id], onDelete: Cascade) + redirectUrl String? +} + enum ReadStatus { NOT_OPENED OPENED @@ -550,6 +562,7 @@ model Template { team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade) templateDocumentData DocumentData @relation(fields: [templateDocumentDataId], references: [id], onDelete: Cascade) + templateDocumentMeta TemplateDocumentMeta? User User @relation(fields: [userId], references: [id], onDelete: Cascade) Recipient Recipient[] Field Field[] diff --git a/packages/ui/primitives/template-flow/add-template-settings.tsx b/packages/ui/primitives/template-flow/add-template-settings.tsx new file mode 100644 index 000000000..7c8b82192 --- /dev/null +++ b/packages/ui/primitives/template-flow/add-template-settings.tsx @@ -0,0 +1,369 @@ +'use client'; + +import { useEffect } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { InfoIcon } from 'lucide-react'; +import { useForm } from 'react-hook-form'; + +import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats'; +import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth'; +import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones'; +import { DocumentAccessAuth, DocumentActionAuth } from '@documenso/lib/types/document-auth'; +import { DocumentStatus, type Field, type Recipient, SendStatus } from '@documenso/prisma/client'; +import type { DocumentWithData } from '@documenso/prisma/types/document-with-data'; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from '@documenso/ui/primitives/accordion'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; + +import { Combobox } from '../combobox'; +import { + DocumentFlowFormContainerActions, + DocumentFlowFormContainerContent, + DocumentFlowFormContainerFooter, + DocumentFlowFormContainerHeader, + DocumentFlowFormContainerStep, +} from '../document-flow/document-flow-root'; +import { ShowFieldItem } from '../document-flow/show-field-item'; +import type { DocumentFlowStep } from '../document-flow/types'; +import { Input } from '../input'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../select'; +import { useStep } from '../stepper'; +import { Tooltip, TooltipContent, TooltipTrigger } from '../tooltip'; +import type { TTemplateSettingsFormSchema } from './add-template-settings.types'; +import { ZTemplateSettingsFormSchema } from './add-template-settings.types'; + +export type AddSettingsFormProps = { + documentFlow: DocumentFlowStep; + recipients: Recipient[]; + fields: Field[]; + isDocumentEnterprise: boolean; + isDocumentPdfLoaded: boolean; + document: DocumentWithData; + onSubmit: (_data: TTemplateSettingsFormSchema) => void; +}; + +export const AddTemplateSettingsFormPartial = ({ + documentFlow, + recipients, + fields, + isDocumentEnterprise, + isDocumentPdfLoaded, + document, + onSubmit, +}: AddSettingsFormProps) => { + const form = useForm({ + resolver: zodResolver(ZTemplateSettingsFormSchema), + defaultValues: { + title: document.title, + globalAccessAuth: documentAuthOption?.globalAccessAuth || undefined, + globalActionAuth: documentAuthOption?.globalActionAuth || undefined, + meta: { + timezone: document.documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE, + dateFormat: document.documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT, + redirectUrl: document.documentMeta?.redirectUrl ?? '', + }, + }, + }); + + const { stepIndex, currentStep, totalSteps, previousStep } = useStep(); + + const documentHasBeenSent = recipients.some( + (recipient) => recipient.sendStatus === SendStatus.SENT, + ); + + // We almost always want to set the timezone to the user's local timezone to avoid confusion + // when the document is signed. + useEffect(() => { + if (!form.formState.touchedFields.meta?.timezone && !documentHasBeenSent) { + form.setValue('meta.timezone', Intl.DateTimeFormat().resolvedOptions().timeZone); + } + }, [documentHasBeenSent, form, form.setValue, form.formState.touchedFields.meta?.timezone]); + + return ( + <> + + + + {isDocumentPdfLoaded && + fields.map((field, index) => ( + + ))} + +
+
+ ( + + Title + + + + + + + )} + /> + + ( + + + Document access + + + + + + +

+ Document access +

+ +

The authentication required for recipients to view the document.

+ +
    +
  • + Require account - The recipient must be signed in to + view the document +
  • +
  • + None - The document can be accessed directly by the URL + sent to the recipient +
  • +
+
+
+
+ + + + +
+ )} + /> + + {isDocumentEnterprise && ( + ( + + + Recipient action authentication + + + + + + +

+ Global recipient action authentication +

+ +

+ The authentication required for recipients to sign the signature field. +

+ +

+ This can be overriden by setting the authentication requirements + directly on each recipient in the next step. +

+ +
    +
  • + Require account - The recipient must be signed in +
  • +
  • + Require passkey - The recipient must have an account + and passkey configured via their settings +
  • +
  • + Require 2FA - The recipient must have an account and + 2FA enabled via their settings +
  • +
  • + None - No authentication required +
  • +
+
+
+
+ + + + +
+ )} + /> + )} + + + + + Advanced Options + + + +
+ ( + + Date Format + + + + + + + + )} + /> + + ( + + Time Zone + + + value && field.onChange(value)} + disabled={documentHasBeenSent} + /> + + + + + )} + /> + + ( + + + Redirect URL{' '} + + + + + + + Add a URL to redirect the user to once the document is signed + + + + + + + + + + + )} + /> +
+
+
+
+
+
+
+ + + + + + + + ); +}; diff --git a/packages/ui/primitives/template-flow/add-template-settings.types.ts b/packages/ui/primitives/template-flow/add-template-settings.types.ts new file mode 100644 index 000000000..fd98735fb --- /dev/null +++ b/packages/ui/primitives/template-flow/add-template-settings.types.ts @@ -0,0 +1,42 @@ +import { z } from 'zod'; + +import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats'; +import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones'; +import { URL_REGEX } from '@documenso/lib/constants/url-regex'; +import { + ZDocumentAccessAuthTypesSchema, + ZDocumentActionAuthTypesSchema, +} from '@documenso/lib/types/document-auth'; + +export const ZMapNegativeOneToUndefinedSchema = z + .string() + .optional() + .transform((val) => { + if (val === '-1') { + return undefined; + } + + return val; + }); + +export const ZTemplateSettingsFormSchema = z.object({ + title: z.string().trim().min(1, { message: "Title can't be empty" }), + globalAccessAuth: ZMapNegativeOneToUndefinedSchema.pipe( + ZDocumentAccessAuthTypesSchema.optional(), + ), + globalActionAuth: ZMapNegativeOneToUndefinedSchema.pipe( + ZDocumentActionAuthTypesSchema.optional(), + ), + meta: z.object({ + timezone: z.string().optional().default(DEFAULT_DOCUMENT_TIME_ZONE), + dateFormat: z.string().optional().default(DEFAULT_DOCUMENT_DATE_FORMAT), + redirectUrl: z + .string() + .optional() + .refine((value) => value === undefined || value === '' || URL_REGEX.test(value), { + message: 'Please enter a valid URL', + }), + }), +}); + +export type TTemplateSettingsFormSchema = z.infer;