From 31a9127c9e77295e65edb00858a4effa76a7e030 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Fri, 6 Oct 2023 22:54:24 +0000 Subject: [PATCH 1/8] feat: templates --- .../app/(marketing)/singleplayer/client.tsx | 1 + .../single-player-mode-success.tsx | 5 +- .../documents/data-table-action-dropdown.tsx | 3 +- .../templates/[id]/edit-template.tsx | 152 +++++ .../app/(dashboard)/templates/[id]/page.tsx | 81 +++ .../templates/data-table-action-dropdown.tsx | 79 +++ .../templates/data-table-templates.tsx | 136 +++++ .../templates/data-table-title.tsx | 26 + .../templates/delete-template-dialog.tsx | 84 +++ .../templates/duplicate-template-dialog.tsx | 89 +++ .../app/(dashboard)/templates/empty-state.tsx | 17 + .../templates/new-template-dialog.tsx | 217 +++++++ .../src/app/(dashboard)/templates/page.tsx | 49 ++ .../(dashboard)/layout/desktop-nav.tsx | 43 +- .../(dashboard)/layout/profile-dropdown.tsx | 8 + .../components/formatter/template-type.tsx | 50 ++ .../add-template-fields.action.ts | 32 ++ .../add-template-placeholders.action.ts | 28 + .../server-only/admin/get-recipients-stats.ts | 7 +- .../server-only/document/find-documents.ts | 4 +- .../field/get-fields-for-template.ts | 22 + .../field/remove-signed-field-with-token.ts | 4 + .../field/set-fields-for-template.ts | 116 ++++ .../field/sign-field-with-token.ts | 4 + .../recipient/get-recipients-for-template.ts | 25 + .../recipient/set-recipients-for-template.ts | 98 ++++ .../template/create-document-from-template.ts | 78 +++ .../server-only/template/create-template.ts | 20 + .../server-only/template/delete-template.ts | 12 + .../template/duplicate-template.ts | 75 +++ .../template/get-template-by-id.ts | 18 + .../lib/server-only/template/get-templates.ts | 37 ++ .../20231007013737_templates/migration.sql | 52 ++ .../migration.sql | 15 + .../migration.sql | 14 + .../migration.sql | 8 + .../migration.sql | 8 + .../migration.sql | 9 + .../migration.sql | 51 ++ .../20231017042227_fix_typo/migration.sql | 23 + .../migration.sql | 8 + .../migration.sql | 8 + .../migration.sql | 5 + .../migration.sql | 10 + .../migration.sql | 54 ++ packages/prisma/schema.prisma | 53 +- packages/trpc/server/router.ts | 2 + .../trpc/server/template-router/router.ts | 94 +++ .../trpc/server/template-router/schema.ts | 26 + packages/ui/primitives/document-dropzone.tsx | 13 +- packages/ui/primitives/document-flow/types.ts | 2 +- .../template-flow/add-template-fields.tsx | 539 ++++++++++++++++++ .../add-template-fields.types.ts | 23 + .../add-template-placeholder-recipients.tsx | 205 +++++++ ...d-template-placeholder-recipients.types.ts | 26 + 55 files changed, 2834 insertions(+), 34 deletions(-) create mode 100644 apps/web/src/app/(dashboard)/templates/[id]/edit-template.tsx create mode 100644 apps/web/src/app/(dashboard)/templates/[id]/page.tsx create mode 100644 apps/web/src/app/(dashboard)/templates/data-table-action-dropdown.tsx create mode 100644 apps/web/src/app/(dashboard)/templates/data-table-templates.tsx create mode 100644 apps/web/src/app/(dashboard)/templates/data-table-title.tsx create mode 100644 apps/web/src/app/(dashboard)/templates/delete-template-dialog.tsx create mode 100644 apps/web/src/app/(dashboard)/templates/duplicate-template-dialog.tsx create mode 100644 apps/web/src/app/(dashboard)/templates/empty-state.tsx create mode 100644 apps/web/src/app/(dashboard)/templates/new-template-dialog.tsx create mode 100644 apps/web/src/app/(dashboard)/templates/page.tsx create mode 100644 apps/web/src/components/formatter/template-type.tsx create mode 100644 apps/web/src/components/forms/edit-template/add-template-fields.action.ts create mode 100644 apps/web/src/components/forms/edit-template/add-template-placeholders.action.ts create mode 100644 packages/lib/server-only/field/get-fields-for-template.ts create mode 100644 packages/lib/server-only/field/set-fields-for-template.ts create mode 100644 packages/lib/server-only/recipient/get-recipients-for-template.ts create mode 100644 packages/lib/server-only/recipient/set-recipients-for-template.ts create mode 100644 packages/lib/server-only/template/create-document-from-template.ts create mode 100644 packages/lib/server-only/template/create-template.ts create mode 100644 packages/lib/server-only/template/delete-template.ts create mode 100644 packages/lib/server-only/template/duplicate-template.ts create mode 100644 packages/lib/server-only/template/get-template-by-id.ts create mode 100644 packages/lib/server-only/template/get-templates.ts create mode 100644 packages/prisma/migrations/20231007013737_templates/migration.sql create mode 100644 packages/prisma/migrations/20231007014431_templates_type/migration.sql create mode 100644 packages/prisma/migrations/20231007021427_reuse_document_data/migration.sql create mode 100644 packages/prisma/migrations/20231007033447_remove_inserted_on_template_field/migration.sql create mode 100644 packages/prisma/migrations/20231007080315_document_name_to_template/migration.sql create mode 100644 packages/prisma/migrations/20231007211915_template_created_date/migration.sql create mode 100644 packages/prisma/migrations/20231017041643_placeholder_recipients/migration.sql create mode 100644 packages/prisma/migrations/20231017042227_fix_typo/migration.sql create mode 100644 packages/prisma/migrations/20231019043226_template_recipient_email/migration.sql create mode 100644 packages/prisma/migrations/20231020032507_template_recipient_token/migration.sql create mode 100644 packages/prisma/migrations/20231021193915_template_token_for_recipient/migration.sql create mode 100644 packages/prisma/migrations/20231030061522_recipient_name_not_placeholder/migration.sql create mode 100644 packages/prisma/migrations/20231208090322_remove_template_specific_models/migration.sql create mode 100644 packages/trpc/server/template-router/router.ts create mode 100644 packages/trpc/server/template-router/schema.ts create mode 100644 packages/ui/primitives/template-flow/add-template-fields.tsx create mode 100644 packages/ui/primitives/template-flow/add-template-fields.types.ts create mode 100644 packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx create mode 100644 packages/ui/primitives/template-flow/add-template-placeholder-recipients.types.ts diff --git a/apps/marketing/src/app/(marketing)/singleplayer/client.tsx b/apps/marketing/src/app/(marketing)/singleplayer/client.tsx index b7654c7cf..71f6963a2 100644 --- a/apps/marketing/src/app/(marketing)/singleplayer/client.tsx +++ b/apps/marketing/src/app/(marketing)/singleplayer/client.tsx @@ -151,6 +151,7 @@ export const SinglePlayerClient = () => { email: '', name: '', token: '', + templateToken: '', expired: null, signedAt: null, readStatus: 'OPENED', diff --git a/apps/marketing/src/components/(marketing)/single-player-mode/single-player-mode-success.tsx b/apps/marketing/src/components/(marketing)/single-player-mode/single-player-mode-success.tsx index aa423e522..1af71c775 100644 --- a/apps/marketing/src/components/(marketing)/single-player-mode/single-player-mode-success.tsx +++ b/apps/marketing/src/components/(marketing)/single-player-mode/single-player-mode-success.tsx @@ -6,8 +6,9 @@ import Link from 'next/link'; import signingCelebration from '@documenso/assets/images/signing-celebration.png'; import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag'; -import { DocumentStatus, Signature } from '@documenso/prisma/client'; -import { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient'; +import type { Signature } from '@documenso/prisma/client'; +import { DocumentStatus } from '@documenso/prisma/client'; +import type { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient'; import DocumentDialog from '@documenso/ui/components/document/document-dialog'; import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button'; import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button'; diff --git a/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx b/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx index 9c3532f88..f1cbcc147 100644 --- a/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx +++ b/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx @@ -99,6 +99,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) = }; const nonSignedRecipients = row.Recipient.filter((item) => item.signingStatus !== 'SIGNED'); + return ( @@ -127,7 +128,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) = Download - setDuplicateDialogOpen(true)}> + Duplicate diff --git a/apps/web/src/app/(dashboard)/templates/[id]/edit-template.tsx b/apps/web/src/app/(dashboard)/templates/[id]/edit-template.tsx new file mode 100644 index 000000000..b4d20b60d --- /dev/null +++ b/apps/web/src/app/(dashboard)/templates/[id]/edit-template.tsx @@ -0,0 +1,152 @@ +'use client'; + +import { useState } from 'react'; + +import { useRouter } from 'next/navigation'; + +import { DocumentData, Field, Recipient, Template, User } from '@documenso/prisma/client'; +import { cn } from '@documenso/ui/lib/utils'; +import { Card, CardContent } from '@documenso/ui/primitives/card'; +import { + DocumentFlowFormContainer, + DocumentFlowFormContainerHeader, +} from '@documenso/ui/primitives/document-flow/document-flow-root'; +import { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types'; +import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; +import { AddTemplateFieldsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-fields'; +import { TAddTemplateFieldsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-fields.types'; +import { AddTemplatePlaceholderRecipientsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-placeholder-recipients'; +import { TAddTemplatePlacholderRecipientsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-placeholder-recipients.types'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { addTemplateFields } from '~/components/forms/edit-template/add-template-fields.action'; +import { addTemplatePlaceholders } from '~/components/forms/edit-template/add-template-placeholders.action'; + +export type EditTemplateFormProps = { + className?: string; + user: User; + template: Template; + recipients: Recipient[]; + fields: Field[]; + documentData: DocumentData; +}; + +type EditTemplateStep = 'signers' | 'fields'; + +export const EditTemplateForm = ({ + className, + template, + recipients, + fields, + user: _user, + documentData, +}: EditTemplateFormProps) => { + const { toast } = useToast(); + const router = useRouter(); + + const [step, setStep] = useState('signers'); + + const documentFlow: Record = { + signers: { + title: 'Add Placeholders', + description: 'Add all relevant placeholders for each recipient.', + stepIndex: 1, + }, + fields: { + title: 'Add Fields', + description: 'Add all relevant fields for each recipient.', + stepIndex: 2, + onBackStep: () => setStep('signers'), + }, + }; + + const currentDocumentFlow = documentFlow[step]; + + const onAddTemplatePlaceholderFormSubmit = async ( + data: TAddTemplatePlacholderRecipientsFormSchema, + ) => { + try { + await addTemplatePlaceholders({ + templateId: template.id, + signers: data.signers, + }); + + router.refresh(); + + setStep('fields'); + } catch (err) { + toast({ + title: 'Error', + description: 'An error occurred while adding signers.', + variant: 'destructive', + }); + } + }; + + const onAddFieldsFormSubmit = async (data: TAddTemplateFieldsFormSchema) => { + try { + await addTemplateFields({ + templateId: template.id, + fields: data.fields, + }); + + toast({ + title: 'Template saved', + description: 'Your templates has been saved successfully.', + duration: 5000, + }); + + router.push('/templates'); + } catch (err) { + toast({ + title: 'Error', + description: 'An error occurred while adding signers.', + variant: 'destructive', + }); + } + }; + + return ( +
+ + + + + + +
+ e.preventDefault()}> + + + {step === 'signers' && ( + + )} + + {step === 'fields' && ( + + )} + +
+
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/templates/[id]/page.tsx b/apps/web/src/app/(dashboard)/templates/[id]/page.tsx new file mode 100644 index 000000000..b8c645c80 --- /dev/null +++ b/apps/web/src/app/(dashboard)/templates/[id]/page.tsx @@ -0,0 +1,81 @@ +import React from 'react'; + +import Link from 'next/link'; +import { redirect } from 'next/navigation'; + +import { ChevronLeft } from 'lucide-react'; + +import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session'; +import { getFieldsForTemplate } from '@documenso/lib/server-only/field/get-fields-for-template'; +import { getRecipientsForTemplate } from '@documenso/lib/server-only/recipient/get-recipients-for-template'; +import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id'; + +import { TemplateType } from '~/components/formatter/template-type'; + +import { EditTemplateForm } from './edit-template'; + +export type TemplatePageProps = { + params: { + id: string; + }; +}; + +export default async function TemplatePage({ params }: TemplatePageProps) { + const { id } = params; + + const templateId = Number(id); + + if (!templateId || Number.isNaN(templateId)) { + redirect('/documents'); + } + + const { user } = await getRequiredServerComponentSession(); + + const template = await getTemplateById({ + id: templateId, + userId: user.id, + }).catch(() => null); + + if (!template || !template.templateDocumentData) { + redirect('/documents'); + } + + const { templateDocumentData } = template; + + const [templateRecipients, templateFields] = await Promise.all([ + await getRecipientsForTemplate({ + templateId, + userId: user.id, + }), + await getFieldsForTemplate({ + templateId, + userId: user.id, + }), + ]); + + return ( +
+ + + Templates + + +

+ {template.title} +

+ +
+ +
+ + +
+ ); +} diff --git a/apps/web/src/app/(dashboard)/templates/data-table-action-dropdown.tsx b/apps/web/src/app/(dashboard)/templates/data-table-action-dropdown.tsx new file mode 100644 index 000000000..15ad9b58b --- /dev/null +++ b/apps/web/src/app/(dashboard)/templates/data-table-action-dropdown.tsx @@ -0,0 +1,79 @@ +'use client'; + +import { useState } from 'react'; + +import Link from 'next/link'; + +import { Copy, Edit, MoreHorizontal, Trash2 } from 'lucide-react'; +import { useSession } from 'next-auth/react'; + +import { Template } from '@documenso/prisma/client'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from '@documenso/ui/primitives/dropdown-menu'; + +import { DeleteTemplateDialog } from './delete-template-dialog'; +import { DuplicateTemplateDialog } from './duplicate-template-dialog'; + +export type DataTableActionDropdownProps = { + row: Template; +}; + +export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) => { + const { data: session } = useSession(); + + const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false); + + if (!session) { + return null; + } + + const isOwner = row.userId === session.user.id; + + return ( + + + + + + + Action + + + + + Edit + + + + {/* onDuplicateButtonClick(row.id)}> */} + setDuplicateDialogOpen(true)}> + + Duplicate + + + setDeleteDialogOpen(true)}> + + Delete + + + + + + + + ); +}; diff --git a/apps/web/src/app/(dashboard)/templates/data-table-templates.tsx b/apps/web/src/app/(dashboard)/templates/data-table-templates.tsx new file mode 100644 index 000000000..3cc8102e7 --- /dev/null +++ b/apps/web/src/app/(dashboard)/templates/data-table-templates.tsx @@ -0,0 +1,136 @@ +'use client'; + +import { useState, useTransition } from 'react'; + +import { useRouter } from 'next/navigation'; + +import { Loader, Plus } from 'lucide-react'; + +import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; +import { Template } from '@documenso/prisma/client'; +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { DataTable } from '@documenso/ui/primitives/data-table'; +import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { LocaleDate } from '~/components/formatter/locale-date'; +import { TemplateType } from '~/components/formatter/template-type'; + +import { DataTableActionDropdown } from './data-table-action-dropdown'; +import { DataTableTitle } from './data-table-title'; + +type TemplatesDataTableProps = { + templates: Template[]; + perPage: number; + page: number; + totalPages: number; +}; + +export const TemplatesDataTable = ({ + templates, + perPage, + page, + totalPages, +}: TemplatesDataTableProps) => { + const [isPending, startTransition] = useTransition(); + const updateSearchParams = useUpdateSearchParams(); + + const router = useRouter(); + + const { toast } = useToast(); + const [loadingStates, setLoadingStates] = useState<{ [key: string]: boolean }>({}); + + const { mutateAsync: createDocumentFromTemplate } = + trpc.template.createDocumentFromTemplate.useMutation(); + + const onPaginationChange = (page: number, perPage: number) => { + startTransition(() => { + updateSearchParams({ + page, + perPage, + }); + }); + }; + + const onUseButtonClick = async (templateId: number) => { + try { + const { id } = await createDocumentFromTemplate({ + templateId, + }); + toast({ + title: 'Document created', + description: 'Your document has been created from the template successfully.', + duration: 5000, + }); + router.push(`/documents/${id}`); + } catch (err) { + toast({ + title: 'Error', + description: 'An error occurred while creating document from template.', + variant: 'destructive', + }); + } + }; + + return ( +
+ , + }, + { + header: 'Title', + cell: ({ row }) => , + }, + { + header: 'Type', + accessorKey: 'type', + cell: ({ row }) => , + }, + { + header: 'Actions', + accessorKey: 'actions', + cell: ({ row }) => { + const isRowLoading = loadingStates[row.original.id]; + + return ( +
+ + +
+ ); + }, + }, + ]} + data={templates} + perPage={perPage} + currentPage={page} + totalPages={totalPages} + onPaginationChange={onPaginationChange} + > + {(table) => } +
+ + {isPending && ( +
+ +
+ )} +
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/templates/data-table-title.tsx b/apps/web/src/app/(dashboard)/templates/data-table-title.tsx new file mode 100644 index 000000000..31e1011be --- /dev/null +++ b/apps/web/src/app/(dashboard)/templates/data-table-title.tsx @@ -0,0 +1,26 @@ +import Link from 'next/link'; + +import { useSession } from 'next-auth/react'; + +import { Template } from '@documenso/prisma/client'; + +export type DataTableTitleProps = { + row: Template; +}; + +export const DataTableTitle = ({ row }: DataTableTitleProps) => { + const { data: session } = useSession(); + + if (!session) { + return null; + } + + return ( + + {row.title} + + ); +}; diff --git a/apps/web/src/app/(dashboard)/templates/delete-template-dialog.tsx b/apps/web/src/app/(dashboard)/templates/delete-template-dialog.tsx new file mode 100644 index 000000000..ed7db1e72 --- /dev/null +++ b/apps/web/src/app/(dashboard)/templates/delete-template-dialog.tsx @@ -0,0 +1,84 @@ +import { useRouter } from 'next/navigation'; + +import { trpc as trpcReact } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@documenso/ui/primitives/dialog'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +type DeleteTemplateDialogProps = { + id: number; + open: boolean; + onOpenChange: (_open: boolean) => void; +}; + +export const DeleteTemplateDialog = ({ id, open, onOpenChange }: DeleteTemplateDialogProps) => { + const router = useRouter(); + + const { toast } = useToast(); + + const { mutateAsync: deleteDocument, isLoading } = trpcReact.template.deleteTemplate.useMutation({ + onSuccess: () => { + router.refresh(); + + toast({ + title: 'Template deleted', + description: 'Your document has been successfully deleted.', + duration: 5000, + }); + + onOpenChange(false); + }, + }); + + const onDraftDelete = async () => { + try { + await deleteDocument({ id }); + } catch { + toast({ + title: 'Something went wrong', + description: 'This template could not be deleted at this time. Please try again.', + variant: 'destructive', + duration: 7500, + }); + } + }; + + return ( + !isLoading && onOpenChange(value)}> + + + Do you want to delete this template? + + + Please note that this action is irreversible. Once confirmed, your template will be + permanently deleted. + + + + +
+ + + +
+
+
+
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/templates/duplicate-template-dialog.tsx b/apps/web/src/app/(dashboard)/templates/duplicate-template-dialog.tsx new file mode 100644 index 000000000..5c3118035 --- /dev/null +++ b/apps/web/src/app/(dashboard)/templates/duplicate-template-dialog.tsx @@ -0,0 +1,89 @@ +import { useRouter } from 'next/navigation'; + +import { trpc as trpcReact } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@documenso/ui/primitives/dialog'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +type DuplicateTemplateDialogProps = { + id: number; + open: boolean; + onOpenChange: (_open: boolean) => void; +}; + +export const DuplicateTemplateDialog = ({ + id, + open, + onOpenChange, +}: DuplicateTemplateDialogProps) => { + const router = useRouter(); + + const { toast } = useToast(); + + const { mutateAsync: duplicateTemplate, isLoading } = + trpcReact.template.duplicateTemplate.useMutation({ + onSuccess: () => { + router.refresh(); + + toast({ + title: 'Template duplicated', + description: 'Your template has been duplicated successfully.', + duration: 5000, + }); + + onOpenChange(false); + }, + }); + + const onDuplicate = async () => { + try { + await duplicateTemplate({ + templateId: id, + }); + + router.refresh(); + } catch (err) { + toast({ + title: 'Error', + description: 'An error occurred while duplicating template.', + variant: 'destructive', + }); + } + }; + + return ( + !isLoading && onOpenChange(value)}> + + + Do you want to duplicate this template? + + Your template will be duplicated. + + + +
+ + + +
+
+
+
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/templates/empty-state.tsx b/apps/web/src/app/(dashboard)/templates/empty-state.tsx new file mode 100644 index 000000000..b928d8a83 --- /dev/null +++ b/apps/web/src/app/(dashboard)/templates/empty-state.tsx @@ -0,0 +1,17 @@ +import { Bird } from 'lucide-react'; + +export const EmptyTemplateState = () => { + return ( +
+ + +
+

We're all empty

+ +

+ You have not yet created any templates. To create a template please upload one. +

+
+
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/templates/new-template-dialog.tsx b/apps/web/src/app/(dashboard)/templates/new-template-dialog.tsx new file mode 100644 index 000000000..7de1355a7 --- /dev/null +++ b/apps/web/src/app/(dashboard)/templates/new-template-dialog.tsx @@ -0,0 +1,217 @@ +'use client'; + +import React, { useState } from 'react'; + +import { useRouter } from 'next/navigation'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { FilePlus, X } from 'lucide-react'; +import { useForm } from 'react-hook-form'; +import * as z from 'zod'; + +import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data'; +import { base64 } from '@documenso/lib/universal/base64'; +import { putFile } from '@documenso/lib/universal/upload/put-file'; +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { Card, CardContent } from '@documenso/ui/primitives/card'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone'; +import { + Form, + FormControl, + FormDescription, + 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 { useToast } from '@documenso/ui/primitives/use-toast'; + +const ZCreateTemplateFormSchema = z.object({ + name: z.string(), +}); + +type TCreateTemplateFormSchema = z.infer; + +export const NewTemplateDialog = () => { + const router = useRouter(); + const { toast } = useToast(); + const form = useForm({ + resolver: zodResolver(ZCreateTemplateFormSchema), + defaultValues: { + name: '', + }, + }); + + const { mutateAsync: createTemplate, isLoading: isCreatingTemplate } = + trpc.template.createTemplate.useMutation(); + + const [showNewTemplateDialog, setShowNewTemplateDialog] = useState(false); + const [uploadedFile, setUploadedFile] = useState<{ file: File; fileBase64: string } | null>(); + + const onFileDrop = async (file: File) => { + try { + const arrayBuffer = await file.arrayBuffer(); + const base64String = base64.encode(new Uint8Array(arrayBuffer)); + + setUploadedFile({ + file, + fileBase64: `data:application/pdf;base64,${base64String}`, + }); + + if (!form.getValues('name')) { + form.setValue('name', file.name); + } + } catch { + toast({ + title: 'Something went wrong', + description: 'Please try again later.', + variant: 'destructive', + }); + } + }; + + const onSubmit = async (values: TCreateTemplateFormSchema) => { + if (!uploadedFile) { + return; + } + + const file: File = uploadedFile.file; + + try { + const { type, data } = await putFile(file); + + const { id: templateDocumentDataId } = await createDocumentData({ + type, + data, + }); + + const { id } = await createTemplate({ + title: values.name ? values.name : file.name, + templateDocumentDataId, + }); + + toast({ + title: 'Template document uploaded', + description: + 'Your document has been uploaded successfully. You will be redirected to the template page.', + duration: 5000, + }); + + setShowNewTemplateDialog(false); + + void router.push(`/templates/${id}`); + } catch { + toast({ + title: 'Something went wrong', + description: 'Please try again later.', + variant: 'destructive', + }); + } + }; + + const resetForm = () => { + if (form.getValues('name') === uploadedFile?.file.name) { + form.reset(); + } + + setUploadedFile(null); + }; + + return ( + + + + + + + New Template + + +
+ + ( + + Name your template + + + + + + Leave this empty if you would like to use your document's name for the + template + + + + + )} + /> + +
+ +
+ {uploadedFile ? ( + + +
resetForm()} + className="absolute right-2 top-2 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none disabled:pointer-events-none" + > + + Remove Template +
+ +
+
+
+
+
+

+ Uploaded Document +

+ + + {uploadedFile.file.name} + + + + ) : ( + + )} +
+
+ +
+ +
+ + + + +
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/templates/page.tsx b/apps/web/src/app/(dashboard)/templates/page.tsx new file mode 100644 index 000000000..bc6a90b12 --- /dev/null +++ b/apps/web/src/app/(dashboard)/templates/page.tsx @@ -0,0 +1,49 @@ +import React from 'react'; + +import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session'; +import { getTemplates } from '@documenso/lib/server-only/template/get-templates'; + +import { TemplatesDataTable } from './data-table-templates'; +import { EmptyTemplateState } from './empty-state'; +import { NewTemplateDialog } from './new-template-dialog'; + +type TemplatesPageProps = { + searchParams?: { + page?: number; + perPage?: number; + }; +}; + +export default async function TemplatesPage({ searchParams = {} }: TemplatesPageProps) { + const { user } = await getRequiredServerComponentSession(); + const page = Number(searchParams.page) || 1; + const perPage = Number(searchParams.perPage) || 10; + + const { templates, totalPages } = await getTemplates({ + userId: user.id, + page: page, + perPage: perPage, + }); + + return ( +
+
+

Templates

+ +
+ +
+ {templates.length > 0 ? ( + + ) : ( + + )} +
+
+ ); +} diff --git a/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx b/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx index 76077cb04..bb3384d0a 100644 --- a/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx +++ b/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx @@ -3,6 +3,9 @@ import type { HTMLAttributes } from 'react'; import { useEffect, useState } from 'react'; +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; + import { Search } from 'lucide-react'; import { cn } from '@documenso/ui/lib/utils'; @@ -10,10 +13,22 @@ import { Button } from '@documenso/ui/primitives/button'; import { CommandMenu } from '../common/command-menu'; +const navigationLinks = [ + { + href: '/documents', + label: 'Documents', + }, + { + href: '/templates', + label: 'Templates', + }, +]; + export type DesktopNavProps = HTMLAttributes; export const DesktopNav = ({ className, ...props }: DesktopNavProps) => { - // const pathname = usePathname(); + const pathname = usePathname(); + const [open, setOpen] = useState(false); const [modifierKey, setModifierKey] = useState(() => 'Ctrl'); @@ -48,18 +63,20 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => { - {/* We have no other subpaths rn */} - {/* - Documents - */} + {navigationLinks.map(({ href, label }) => ( + + {label} + + ))} ); }; diff --git a/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx b/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx index e488ba6e9..2dcbb9864 100644 --- a/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx +++ b/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx @@ -4,6 +4,7 @@ import Link from 'next/link'; import { CreditCard, + FileSpreadsheet, Lock, LogOut, User as LucideUser, @@ -106,6 +107,13 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
)} + + + + + Templates + + diff --git a/apps/web/src/components/formatter/template-type.tsx b/apps/web/src/components/formatter/template-type.tsx new file mode 100644 index 000000000..a7f10105e --- /dev/null +++ b/apps/web/src/components/formatter/template-type.tsx @@ -0,0 +1,50 @@ +import { HTMLAttributes } from 'react'; + +import { Globe, Lock } from 'lucide-react'; +import type { LucideIcon } from 'lucide-react/dist/lucide-react'; + +import { TemplateType as TemplateTypePrisma } from '@documenso/prisma/client'; +import { cn } from '@documenso/ui/lib/utils'; + +type TemplateTypeIcon = { + label: string; + icon?: LucideIcon; + color: string; +}; + +type TemplateTypes = (typeof TemplateTypePrisma)[keyof typeof TemplateTypePrisma]; + +const TEMPLATE_TYPES: Record = { + PRIVATE: { + label: 'Private', + icon: Lock, + color: 'text-blue-600 dark:text-blue-300', + }, + PUBLIC: { + label: 'Public', + icon: Globe, + color: 'text-green-500 dark:text-green-300', + }, +}; + +export type TemplateTypeProps = HTMLAttributes & { + type: TemplateTypes; + inheritColor?: boolean; +}; + +export const TemplateType = ({ className, type, inheritColor, ...props }: TemplateTypeProps) => { + const { label, icon: Icon, color } = TEMPLATE_TYPES[type]; + + return ( + + {Icon && ( + + )} + {label} + + ); +}; diff --git a/apps/web/src/components/forms/edit-template/add-template-fields.action.ts b/apps/web/src/components/forms/edit-template/add-template-fields.action.ts new file mode 100644 index 000000000..2ee7ee825 --- /dev/null +++ b/apps/web/src/components/forms/edit-template/add-template-fields.action.ts @@ -0,0 +1,32 @@ +'use server'; + +import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session'; +import { setFieldsForTemplate } from '@documenso/lib/server-only/field/set-fields-for-template'; +import { TAddTemplateFieldsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-fields.types'; + +export type AddTemplateFieldsActionInput = TAddTemplateFieldsFormSchema & { + templateId: number; +}; + +export const addTemplateFields = async ({ templateId, fields }: AddTemplateFieldsActionInput) => { + 'use server'; + + const { user } = await getRequiredServerComponentSession(); + + await setFieldsForTemplate({ + userId: user.id, + templateId, + fields: fields.map((field) => ({ + id: field.nativeId, + signerEmail: field.signerEmail, + signerId: field.signerId, + signerToken: field.signerToken, + type: field.type, + pageNumber: field.pageNumber, + pageX: field.pageX, + pageY: field.pageY, + pageWidth: field.pageWidth, + pageHeight: field.pageHeight, + })), + }); +}; diff --git a/apps/web/src/components/forms/edit-template/add-template-placeholders.action.ts b/apps/web/src/components/forms/edit-template/add-template-placeholders.action.ts new file mode 100644 index 000000000..b2183eed1 --- /dev/null +++ b/apps/web/src/components/forms/edit-template/add-template-placeholders.action.ts @@ -0,0 +1,28 @@ +'use server'; + +import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session'; +import { setRecipientsForTemplate } from '@documenso/lib/server-only/recipient/set-recipients-for-template'; +import { TAddTemplatePlacholderRecipientsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-placeholder-recipients.types'; + +export type AddTemplatePlaceholdersActionInput = TAddTemplatePlacholderRecipientsFormSchema & { + templateId: number; +}; + +export const addTemplatePlaceholders = async ({ + templateId, + signers, +}: AddTemplatePlaceholdersActionInput) => { + 'use server'; + + const { user } = await getRequiredServerComponentSession(); + + await setRecipientsForTemplate({ + userId: user.id, + templateId, + recipients: signers.map((signer) => ({ + id: signer.nativeId!, + email: signer.email, + name: signer.name!, + })), + }); +}; diff --git a/packages/lib/server-only/admin/get-recipients-stats.ts b/packages/lib/server-only/admin/get-recipients-stats.ts index f24d0b5a2..b6663e988 100644 --- a/packages/lib/server-only/admin/get-recipients-stats.ts +++ b/packages/lib/server-only/admin/get-recipients-stats.ts @@ -19,9 +19,10 @@ export const getRecipientsStats = async () => { results.forEach((result) => { const { readStatus, signingStatus, sendStatus, _count } = result; - stats[readStatus] += _count; - stats[signingStatus] += _count; - stats[sendStatus] += _count; + stats[readStatus as keyof typeof stats] += _count; + stats.TOTAL_RECIPIENTS += _count; + stats[signingStatus as keyof typeof stats] += _count; + stats[sendStatus as keyof typeof stats] += _count; stats.TOTAL_RECIPIENTS += _count; }); diff --git a/packages/lib/server-only/document/find-documents.ts b/packages/lib/server-only/document/find-documents.ts index a27458a55..18600ebe6 100644 --- a/packages/lib/server-only/document/find-documents.ts +++ b/packages/lib/server-only/document/find-documents.ts @@ -8,7 +8,7 @@ import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-documen import type { FindResultSet } from '../../types/find-result-set'; -export interface FindDocumentsOptions { +export type FindDocumentsOptions = { userId: number; term?: string; status?: ExtendedDocumentStatus; @@ -19,7 +19,7 @@ export interface FindDocumentsOptions { direction: 'asc' | 'desc'; }; period?: '' | '7d' | '14d' | '30d'; -} +}; export const findDocuments = async ({ userId, diff --git a/packages/lib/server-only/field/get-fields-for-template.ts b/packages/lib/server-only/field/get-fields-for-template.ts new file mode 100644 index 000000000..c174d7eff --- /dev/null +++ b/packages/lib/server-only/field/get-fields-for-template.ts @@ -0,0 +1,22 @@ +import { prisma } from '@documenso/prisma'; + +export interface GetFieldsForTemplateOptions { + templateId: number; + userId: number; +} + +export const getFieldsForTemplate = async ({ templateId, userId }: GetFieldsForTemplateOptions) => { + const fields = await prisma.field.findMany({ + where: { + templateId, + Template: { + userId, + }, + }, + orderBy: { + id: 'asc', + }, + }); + + return fields; +}; diff --git a/packages/lib/server-only/field/remove-signed-field-with-token.ts b/packages/lib/server-only/field/remove-signed-field-with-token.ts index 4a28e7627..ee472ec9f 100644 --- a/packages/lib/server-only/field/remove-signed-field-with-token.ts +++ b/packages/lib/server-only/field/remove-signed-field-with-token.ts @@ -27,6 +27,10 @@ export const removeSignedFieldWithToken = async ({ const { Document: document, Recipient: recipient } = field; + if (!document) { + throw new Error(`Document not found for field ${field.id}`); + } + if (document.status === DocumentStatus.COMPLETED) { throw new Error(`Document ${document.id} has already been completed`); } diff --git a/packages/lib/server-only/field/set-fields-for-template.ts b/packages/lib/server-only/field/set-fields-for-template.ts new file mode 100644 index 000000000..6e2e39afc --- /dev/null +++ b/packages/lib/server-only/field/set-fields-for-template.ts @@ -0,0 +1,116 @@ +import { prisma } from '@documenso/prisma'; +import { FieldType } from '@documenso/prisma/client'; + +export type Field = { + id?: number | null; + type: FieldType; + signerEmail: string; + signerId?: number; + pageNumber: number; + pageX: number; + pageY: number; + pageWidth: number; + pageHeight: number; +}; + +export type SetFieldsForTemplateOptions = { + userId: number; + templateId: number; + fields: Field[]; +}; + +export const setFieldsForTemplate = async ({ + userId, + templateId, + fields, +}: SetFieldsForTemplateOptions) => { + const template = await prisma.template.findFirst({ + where: { + id: templateId, + userId, + }, + }); + + if (!template) { + throw new Error('Document not found'); + } + + const existingFields = await prisma.field.findMany({ + where: { + templateId, + }, + include: { + Recipient: true, + }, + }); + + const removedFields = existingFields.filter( + (existingField) => + !fields.find( + (field) => + field.id === existingField.id || field.signerEmail === existingField.Recipient?.email, + ), + ); + + const linkedFields = fields.map((field) => { + const existing = existingFields.find((existingField) => existingField.id === field.id); + + return { + ...field, + _persisted: existing, + }; + }); + + const persistedFields = await prisma.$transaction( + // Disabling as wrapping promises here causes type issues + // eslint-disable-next-line @typescript-eslint/promise-function-async + linkedFields.map((field) => + prisma.field.upsert({ + where: { + id: field._persisted?.id ?? -1, + templateId, + }, + update: { + page: field.pageNumber, + positionX: field.pageX, + positionY: field.pageY, + width: field.pageWidth, + height: field.pageHeight, + }, + create: { + type: field.type, + page: field.pageNumber, + positionX: field.pageX, + positionY: field.pageY, + width: field.pageWidth, + height: field.pageHeight, + customText: '', + inserted: false, + Template: { + connect: { + id: templateId, + }, + }, + Recipient: { + connect: { + id: field.signerId, + email: field.signerEmail, + }, + }, + }, + }), + ), + ); + + if (removedFields.length > 0) { + await prisma.field.deleteMany({ + where: { + id: { + in: removedFields.map((field) => field.id), + }, + }, + }); + } + + return persistedFields; +}; diff --git a/packages/lib/server-only/field/sign-field-with-token.ts b/packages/lib/server-only/field/sign-field-with-token.ts index 6640a6a07..59fab71e5 100644 --- a/packages/lib/server-only/field/sign-field-with-token.ts +++ b/packages/lib/server-only/field/sign-field-with-token.ts @@ -33,6 +33,10 @@ export const signFieldWithToken = async ({ const { Document: document, Recipient: recipient } = field; + if (!document) { + throw new Error(`Document not found for field ${field.id}`); + } + if (document.status === DocumentStatus.COMPLETED) { throw new Error(`Document ${document.id} has already been completed`); } diff --git a/packages/lib/server-only/recipient/get-recipients-for-template.ts b/packages/lib/server-only/recipient/get-recipients-for-template.ts new file mode 100644 index 000000000..ab6f860eb --- /dev/null +++ b/packages/lib/server-only/recipient/get-recipients-for-template.ts @@ -0,0 +1,25 @@ +import { prisma } from '@documenso/prisma'; + +export interface GetRecipientsForTemplateOptions { + templateId: number; + userId: number; +} + +export const getRecipientsForTemplate = async ({ + templateId, + userId, +}: GetRecipientsForTemplateOptions) => { + const recipients = await prisma.recipient.findMany({ + where: { + templateId, + Template: { + userId, + }, + }, + orderBy: { + id: 'asc', + }, + }); + + return recipients; +}; diff --git a/packages/lib/server-only/recipient/set-recipients-for-template.ts b/packages/lib/server-only/recipient/set-recipients-for-template.ts new file mode 100644 index 000000000..2145d47b4 --- /dev/null +++ b/packages/lib/server-only/recipient/set-recipients-for-template.ts @@ -0,0 +1,98 @@ +import { prisma } from '@documenso/prisma'; + +import { nanoid } from '../../universal/id'; + +export type SetRecipientsForTemplateOptions = { + userId: number; + templateId: number; + recipients: { + id?: number; + email: string; + name: string; + }[]; +}; + +export const setRecipientsForTemplate = async ({ + userId, + templateId, + recipients, +}: SetRecipientsForTemplateOptions) => { + const template = await prisma.template.findFirst({ + where: { + id: templateId, + userId, + }, + }); + + if (!template) { + throw new Error('Template not found'); + } + + const normalizedRecipients = recipients.map((recipient) => ({ + ...recipient, + email: recipient.email.toLowerCase(), + })); + + const existingRecipients = await prisma.recipient.findMany({ + where: { + templateId, + }, + }); + + const removedRecipients = existingRecipients.filter( + (existingRecipient) => + !normalizedRecipients.find( + (recipient) => + recipient.id === existingRecipient.id || recipient.email === existingRecipient.email, + ), + ); + + const linkedRecipients = normalizedRecipients.map((recipient) => { + const existing = existingRecipients.find( + (existingRecipient) => + existingRecipient.id === recipient.id || existingRecipient.email === recipient.email, + ); + + return { + ...recipient, + _persisted: existing, + }; + }); + + const persistedRecipients = await prisma.$transaction( + // Disabling as wrapping promises here causes type issues + // eslint-disable-next-line @typescript-eslint/promise-function-async + linkedRecipients.map((recipient) => + prisma.recipient.upsert({ + where: { + id: recipient._persisted?.id ?? -1, + templateId, + }, + update: { + name: recipient.name, + email: recipient.email, + templateId, + }, + create: { + name: recipient.name, + email: recipient.email, + token: nanoid(), + templateToken: nanoid(), + templateId, + }, + }), + ), + ); + + if (removedRecipients.length > 0) { + await prisma.recipient.deleteMany({ + where: { + id: { + in: removedRecipients.map((recipient) => recipient.id), + }, + }, + }); + } + + return persistedRecipients; +}; diff --git a/packages/lib/server-only/template/create-document-from-template.ts b/packages/lib/server-only/template/create-document-from-template.ts new file mode 100644 index 000000000..b0589821f --- /dev/null +++ b/packages/lib/server-only/template/create-document-from-template.ts @@ -0,0 +1,78 @@ +import { nanoid } from '@documenso/lib/universal/id'; +import { prisma } from '@documenso/prisma'; +import { TCreateDocumentFromTemplateMutationSchema } from '@documenso/trpc/server/template-router/schema'; + +export type CreateDocumentFromTemplateOptions = TCreateDocumentFromTemplateMutationSchema & { + userId: number; +}; + +export const createDocumentFromTemplate = async ({ + templateId, + userId, +}: CreateDocumentFromTemplateOptions) => { + const template = await prisma.template.findUnique({ + where: { id: templateId, userId }, + 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, + title: template.title, + documentDataId: documentData.id, + Recipient: { + create: template.Recipient.map((recipient) => ({ + email: recipient.email, + name: recipient.name, + token: nanoid(), + templateToken: recipient.templateToken, + })), + }, + }, + + include: { + Recipient: 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.templateToken === recipient?.templateToken, + ); + + 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 || null, + }; + }), + }); + + return document; +}; diff --git a/packages/lib/server-only/template/create-template.ts b/packages/lib/server-only/template/create-template.ts new file mode 100644 index 000000000..d00526a64 --- /dev/null +++ b/packages/lib/server-only/template/create-template.ts @@ -0,0 +1,20 @@ +import { prisma } from '@documenso/prisma'; +import { TCreateTemplateMutationSchema } from '@documenso/trpc/server/template-router/schema'; + +export type CreateTemplateOptions = TCreateTemplateMutationSchema & { + userId: number; +}; + +export const createTemplate = async ({ + title, + userId, + templateDocumentDataId, +}: CreateTemplateOptions) => { + return await prisma.template.create({ + data: { + title, + userId, + templateDocumentDataId, + }, + }); +}; diff --git a/packages/lib/server-only/template/delete-template.ts b/packages/lib/server-only/template/delete-template.ts new file mode 100644 index 000000000..f693bcec0 --- /dev/null +++ b/packages/lib/server-only/template/delete-template.ts @@ -0,0 +1,12 @@ +'use server'; + +import { prisma } from '@documenso/prisma'; + +export type DeleteTemplateOptions = { + id: number; + userId: number; +}; + +export const deleteTemplate = async ({ id, userId }: DeleteTemplateOptions) => { + return await prisma.template.delete({ where: { id, userId } }); +}; diff --git a/packages/lib/server-only/template/duplicate-template.ts b/packages/lib/server-only/template/duplicate-template.ts new file mode 100644 index 000000000..14806707b --- /dev/null +++ b/packages/lib/server-only/template/duplicate-template.ts @@ -0,0 +1,75 @@ +import { nanoid } from '@documenso/lib/universal/id'; +import { prisma } from '@documenso/prisma'; +import { TDuplicateTemplateMutationSchema } from '@documenso/trpc/server/template-router/schema'; + +export type DuplicateTemplateOptions = TDuplicateTemplateMutationSchema & { + userId: number; +}; + +export const duplicateTemplate = async ({ templateId, userId }: DuplicateTemplateOptions) => { + const template = await prisma.template.findUnique({ + where: { id: templateId, userId }, + 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 duplicatedTemplate = await prisma.template.create({ + data: { + userId, + title: template.title + ' (copy)', + templateDocumentDataId: documentData.id, + Recipient: { + create: template.Recipient.map((recipient) => ({ + email: recipient.email, + name: recipient.name, + token: nanoid(), + templateToken: recipient.templateToken, + })), + }, + }, + + include: { + Recipient: true, + }, + }); + + await prisma.field.createMany({ + data: template.Field.map((field) => { + const recipient = template.Recipient.find((recipient) => recipient.id === field.recipientId); + + const duplicatedTemplateRecipient = duplicatedTemplate.Recipient.find( + (doc) => doc.templateToken === recipient?.templateToken, + ); + + 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, + templateId: duplicatedTemplate.id, + recipientId: duplicatedTemplateRecipient?.id || null, + }; + }), + }); + + return duplicatedTemplate; +}; diff --git a/packages/lib/server-only/template/get-template-by-id.ts b/packages/lib/server-only/template/get-template-by-id.ts new file mode 100644 index 000000000..56f959a9b --- /dev/null +++ b/packages/lib/server-only/template/get-template-by-id.ts @@ -0,0 +1,18 @@ +import { prisma } from '@documenso/prisma'; + +export interface GetTemplateByIdOptions { + id: number; + userId: number; +} + +export const getTemplateById = async ({ id, userId }: GetTemplateByIdOptions) => { + return await prisma.template.findFirstOrThrow({ + where: { + id, + userId, + }, + include: { + templateDocumentData: true, + }, + }); +}; diff --git a/packages/lib/server-only/template/get-templates.ts b/packages/lib/server-only/template/get-templates.ts new file mode 100644 index 000000000..60de7cd89 --- /dev/null +++ b/packages/lib/server-only/template/get-templates.ts @@ -0,0 +1,37 @@ +import { prisma } from '@documenso/prisma'; + +export type GetTemplatesOptions = { + userId: number; + page: number; + perPage: number; +}; + +export const getTemplates = async ({ userId, page = 1, perPage = 10 }: GetTemplatesOptions) => { + const [templates, count] = await Promise.all([ + await prisma.template.findMany({ + where: { + userId, + }, + include: { + templateDocumentData: true, + Field: true, + }, + skip: Math.max(page - 1, 0) * perPage, + orderBy: { + createdAt: 'desc', + }, + }), + await prisma.template.count({ + where: { + User: { + id: userId, + }, + }, + }), + ]); + + return { + templates, + totalPages: Math.ceil(count / perPage), + }; +}; diff --git a/packages/prisma/migrations/20231007013737_templates/migration.sql b/packages/prisma/migrations/20231007013737_templates/migration.sql new file mode 100644 index 000000000..e0c1bf4ec --- /dev/null +++ b/packages/prisma/migrations/20231007013737_templates/migration.sql @@ -0,0 +1,52 @@ +-- CreateEnum +CREATE TYPE "TemplateStatus" AS ENUM ('PUBLIC', 'PRIVATE'); + +-- CreateTable +CREATE TABLE "Template" ( + "id" SERIAL NOT NULL, + "userId" INTEGER NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT, + "status" "TemplateStatus" NOT NULL DEFAULT 'PRIVATE', + "templateDataId" TEXT NOT NULL, + + CONSTRAINT "Template_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TemplateData" ( + "id" TEXT NOT NULL, + "type" "DocumentDataType" NOT NULL, + "data" TEXT NOT NULL, + "initialData" TEXT NOT NULL, + + CONSTRAINT "TemplateData_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TemplateField" ( + "id" SERIAL NOT NULL, + "templateId" INTEGER NOT NULL, + "type" "FieldType" NOT NULL, + "page" INTEGER NOT NULL, + "positionX" DECIMAL(65,30) NOT NULL DEFAULT 0, + "positionY" DECIMAL(65,30) NOT NULL DEFAULT 0, + "width" DECIMAL(65,30) NOT NULL DEFAULT -1, + "height" DECIMAL(65,30) NOT NULL DEFAULT -1, + "customText" TEXT NOT NULL, + "inserted" BOOLEAN NOT NULL, + + CONSTRAINT "TemplateField_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Template_templateDataId_key" ON "Template"("templateDataId"); + +-- AddForeignKey +ALTER TABLE "Template" ADD CONSTRAINT "Template_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Template" ADD CONSTRAINT "Template_templateDataId_fkey" FOREIGN KEY ("templateDataId") REFERENCES "TemplateData"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TemplateField" ADD CONSTRAINT "TemplateField_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "Template"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/prisma/migrations/20231007014431_templates_type/migration.sql b/packages/prisma/migrations/20231007014431_templates_type/migration.sql new file mode 100644 index 000000000..c89e09a61 --- /dev/null +++ b/packages/prisma/migrations/20231007014431_templates_type/migration.sql @@ -0,0 +1,15 @@ +/* + Warnings: + + - The `status` column on the `Template` table would be dropped and recreated. This will lead to data loss if there is data in the column. + +*/ +-- CreateEnum +CREATE TYPE "TemplateType" AS ENUM ('PUBLIC', 'PRIVATE'); + +-- AlterTable +ALTER TABLE "Template" DROP COLUMN "status", +ADD COLUMN "status" "TemplateType" NOT NULL DEFAULT 'PRIVATE'; + +-- DropEnum +DROP TYPE "TemplateStatus"; diff --git a/packages/prisma/migrations/20231007021427_reuse_document_data/migration.sql b/packages/prisma/migrations/20231007021427_reuse_document_data/migration.sql new file mode 100644 index 000000000..629f292fc --- /dev/null +++ b/packages/prisma/migrations/20231007021427_reuse_document_data/migration.sql @@ -0,0 +1,14 @@ +/* + Warnings: + + - You are about to drop the `TemplateData` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "Template" DROP CONSTRAINT "Template_templateDataId_fkey"; + +-- DropTable +DROP TABLE "TemplateData"; + +-- AddForeignKey +ALTER TABLE "Template" ADD CONSTRAINT "Template_templateDataId_fkey" FOREIGN KEY ("templateDataId") REFERENCES "DocumentData"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/prisma/migrations/20231007033447_remove_inserted_on_template_field/migration.sql b/packages/prisma/migrations/20231007033447_remove_inserted_on_template_field/migration.sql new file mode 100644 index 000000000..25ace4f72 --- /dev/null +++ b/packages/prisma/migrations/20231007033447_remove_inserted_on_template_field/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - You are about to drop the column `inserted` on the `TemplateField` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "TemplateField" DROP COLUMN "inserted"; diff --git a/packages/prisma/migrations/20231007080315_document_name_to_template/migration.sql b/packages/prisma/migrations/20231007080315_document_name_to_template/migration.sql new file mode 100644 index 000000000..45a52de3d --- /dev/null +++ b/packages/prisma/migrations/20231007080315_document_name_to_template/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - Added the required column `documentName` to the `Template` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "Template" ADD COLUMN "documentName" TEXT NOT NULL; diff --git a/packages/prisma/migrations/20231007211915_template_created_date/migration.sql b/packages/prisma/migrations/20231007211915_template_created_date/migration.sql new file mode 100644 index 000000000..816da092e --- /dev/null +++ b/packages/prisma/migrations/20231007211915_template_created_date/migration.sql @@ -0,0 +1,9 @@ +/* + Warnings: + + - Added the required column `updatedAt` to the `Template` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "Template" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL; diff --git a/packages/prisma/migrations/20231017041643_placeholder_recipients/migration.sql b/packages/prisma/migrations/20231017041643_placeholder_recipients/migration.sql new file mode 100644 index 000000000..b84a567c8 --- /dev/null +++ b/packages/prisma/migrations/20231017041643_placeholder_recipients/migration.sql @@ -0,0 +1,51 @@ +/* + Warnings: + + - You are about to drop the column `description` on the `Template` table. All the data in the column will be lost. + - You are about to drop the column `documentName` on the `Template` table. All the data in the column will be lost. + - You are about to drop the column `status` on the `Template` table. All the data in the column will be lost. + - You are about to drop the column `templateDataId` on the `Template` table. All the data in the column will be lost. + - A unique constraint covering the columns `[tempateDocumentDataId]` on the table `Template` will be added. If there are existing duplicate values, this will fail. + - Added the required column `tempateDocumentDataId` to the `Template` table without a default value. This is not possible if the table is not empty. + - Added the required column `inserted` to the `TemplateField` table without a default value. This is not possible if the table is not empty. + +*/ +-- DropForeignKey +ALTER TABLE "Template" DROP CONSTRAINT "Template_templateDataId_fkey"; + +-- DropIndex +DROP INDEX "Template_templateDataId_key"; + +-- AlterTable +ALTER TABLE "Template" DROP COLUMN "description", +DROP COLUMN "documentName", +DROP COLUMN "status", +DROP COLUMN "templateDataId", +ADD COLUMN "tempateDocumentDataId" TEXT NOT NULL, +ADD COLUMN "type" "TemplateType" NOT NULL DEFAULT 'PRIVATE', +ALTER COLUMN "updatedAt" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "TemplateField" ADD COLUMN "inserted" BOOLEAN NOT NULL, +ADD COLUMN "recipientId" INTEGER; + +-- CreateTable +CREATE TABLE "TemplateRecipient" ( + "id" SERIAL NOT NULL, + "templateId" INTEGER NOT NULL, + "placeholder" VARCHAR(255) NOT NULL, + + CONSTRAINT "TemplateRecipient_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Template_tempateDocumentDataId_key" ON "Template"("tempateDocumentDataId"); + +-- AddForeignKey +ALTER TABLE "Template" ADD CONSTRAINT "Template_tempateDocumentDataId_fkey" FOREIGN KEY ("tempateDocumentDataId") REFERENCES "DocumentData"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TemplateRecipient" ADD CONSTRAINT "TemplateRecipient_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "Template"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TemplateField" ADD CONSTRAINT "TemplateField_recipientId_fkey" FOREIGN KEY ("recipientId") REFERENCES "TemplateRecipient"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/prisma/migrations/20231017042227_fix_typo/migration.sql b/packages/prisma/migrations/20231017042227_fix_typo/migration.sql new file mode 100644 index 000000000..ac9eaf10e --- /dev/null +++ b/packages/prisma/migrations/20231017042227_fix_typo/migration.sql @@ -0,0 +1,23 @@ +/* + Warnings: + + - You are about to drop the column `tempateDocumentDataId` on the `Template` table. All the data in the column will be lost. + - A unique constraint covering the columns `[templateDocumentDataId]` on the table `Template` will be added. If there are existing duplicate values, this will fail. + - Added the required column `templateDocumentDataId` to the `Template` table without a default value. This is not possible if the table is not empty. + +*/ +-- DropForeignKey +ALTER TABLE "Template" DROP CONSTRAINT "Template_tempateDocumentDataId_fkey"; + +-- DropIndex +DROP INDEX "Template_tempateDocumentDataId_key"; + +-- AlterTable +ALTER TABLE "Template" DROP COLUMN "tempateDocumentDataId", +ADD COLUMN "templateDocumentDataId" TEXT NOT NULL; + +-- CreateIndex +CREATE UNIQUE INDEX "Template_templateDocumentDataId_key" ON "Template"("templateDocumentDataId"); + +-- AddForeignKey +ALTER TABLE "Template" ADD CONSTRAINT "Template_templateDocumentDataId_fkey" FOREIGN KEY ("templateDocumentDataId") REFERENCES "DocumentData"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/prisma/migrations/20231019043226_template_recipient_email/migration.sql b/packages/prisma/migrations/20231019043226_template_recipient_email/migration.sql new file mode 100644 index 000000000..266333794 --- /dev/null +++ b/packages/prisma/migrations/20231019043226_template_recipient_email/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - Added the required column `email` to the `TemplateRecipient` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "TemplateRecipient" ADD COLUMN "email" VARCHAR(255) NOT NULL; diff --git a/packages/prisma/migrations/20231020032507_template_recipient_token/migration.sql b/packages/prisma/migrations/20231020032507_template_recipient_token/migration.sql new file mode 100644 index 000000000..9ce1fa70a --- /dev/null +++ b/packages/prisma/migrations/20231020032507_template_recipient_token/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - Added the required column `token` to the `TemplateRecipient` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "TemplateRecipient" ADD COLUMN "token" TEXT NOT NULL; diff --git a/packages/prisma/migrations/20231021193915_template_token_for_recipient/migration.sql b/packages/prisma/migrations/20231021193915_template_token_for_recipient/migration.sql new file mode 100644 index 000000000..a6b3e7199 --- /dev/null +++ b/packages/prisma/migrations/20231021193915_template_token_for_recipient/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "Recipient" ADD COLUMN "templateToken" TEXT; + +-- AlterTable +ALTER TABLE "TemplateRecipient" ADD COLUMN "templateToken" TEXT; diff --git a/packages/prisma/migrations/20231030061522_recipient_name_not_placeholder/migration.sql b/packages/prisma/migrations/20231030061522_recipient_name_not_placeholder/migration.sql new file mode 100644 index 000000000..8b9275c68 --- /dev/null +++ b/packages/prisma/migrations/20231030061522_recipient_name_not_placeholder/migration.sql @@ -0,0 +1,10 @@ +/* + Warnings: + + - You are about to drop the column `placeholder` on the `TemplateRecipient` table. All the data in the column will be lost. + - Added the required column `name` to the `TemplateRecipient` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "TemplateRecipient" DROP COLUMN "placeholder", +ADD COLUMN "name" VARCHAR(255) NOT NULL; diff --git a/packages/prisma/migrations/20231208090322_remove_template_specific_models/migration.sql b/packages/prisma/migrations/20231208090322_remove_template_specific_models/migration.sql new file mode 100644 index 000000000..7e2af3ef8 --- /dev/null +++ b/packages/prisma/migrations/20231208090322_remove_template_specific_models/migration.sql @@ -0,0 +1,54 @@ +/* + Warnings: + + - You are about to drop the `TemplateField` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `TemplateRecipient` table. If the table is not empty, all the data it contains will be lost. + - A unique constraint covering the columns `[templateId,email]` on the table `Recipient` will be added. If there are existing duplicate values, this will fail. + +*/ +-- DropForeignKey +ALTER TABLE "Field" DROP CONSTRAINT "Field_recipientId_fkey"; + +-- DropForeignKey +ALTER TABLE "TemplateField" DROP CONSTRAINT "TemplateField_recipientId_fkey"; + +-- DropForeignKey +ALTER TABLE "TemplateField" DROP CONSTRAINT "TemplateField_templateId_fkey"; + +-- DropForeignKey +ALTER TABLE "TemplateRecipient" DROP CONSTRAINT "TemplateRecipient_templateId_fkey"; + +-- AlterTable +ALTER TABLE "Field" ADD COLUMN "templateId" INTEGER, +ALTER COLUMN "documentId" DROP NOT NULL; + +-- AlterTable +ALTER TABLE "Recipient" ADD COLUMN "templateId" INTEGER, +ALTER COLUMN "documentId" DROP NOT NULL, +ALTER COLUMN "readStatus" DROP NOT NULL, +ALTER COLUMN "signingStatus" DROP NOT NULL, +ALTER COLUMN "sendStatus" DROP NOT NULL; + +-- DropTable +DROP TABLE "TemplateField"; + +-- DropTable +DROP TABLE "TemplateRecipient"; + +-- CreateIndex +CREATE INDEX "Field_templateId_idx" ON "Field"("templateId"); + +-- CreateIndex +CREATE INDEX "Recipient_templateId_idx" ON "Recipient"("templateId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Recipient_templateId_email_key" ON "Recipient"("templateId", "email"); + +-- AddForeignKey +ALTER TABLE "Recipient" ADD CONSTRAINT "Recipient_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "Template"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Field" ADD CONSTRAINT "Field_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "Template"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Field" ADD CONSTRAINT "Field_recipientId_fkey" FOREIGN KEY ("recipientId") REFERENCES "Recipient"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 75c175adc..d21c4c637 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -40,6 +40,7 @@ model User { twoFactorEnabled Boolean @default(false) twoFactorBackupCodes String? VerificationToken VerificationToken[] + Template Template[] @@index([email]) } @@ -154,6 +155,7 @@ model DocumentData { data String initialData String Document Document? + Template Template? } model DocumentMeta { @@ -180,22 +182,27 @@ enum SigningStatus { } model Recipient { - id Int @id @default(autoincrement()) - documentId Int - email String @db.VarChar(255) - name String @default("") @db.VarChar(255) + id Int @id @default(autoincrement()) + documentId Int? + templateId Int? + email String @db.VarChar(255) + name String @default("") @db.VarChar(255) token String + templateToken String? expired DateTime? signedAt DateTime? - readStatus ReadStatus @default(NOT_OPENED) - signingStatus SigningStatus @default(NOT_SIGNED) - sendStatus SendStatus @default(NOT_SENT) - Document Document @relation(fields: [documentId], references: [id], onDelete: Cascade) + readStatus ReadStatus? @default(NOT_OPENED) + signingStatus SigningStatus? @default(NOT_SIGNED) + sendStatus SendStatus? @default(NOT_SENT) + Document Document? @relation(fields: [documentId], references: [id], onDelete: Cascade) + Template Template? @relation(fields: [templateId], references: [id], onDelete: Cascade) Field Field[] Signature Signature[] @@unique([documentId, email]) + @@unique([templateId, email]) @@index([documentId]) + @@index([templateId]) @@index([token]) } @@ -210,7 +217,8 @@ enum FieldType { model Field { id Int @id @default(autoincrement()) - documentId Int + documentId Int? + templateId Int? recipientId Int? type FieldType page Int @@ -220,11 +228,13 @@ model Field { height Decimal @default(-1) customText String inserted Boolean - Document Document @relation(fields: [documentId], references: [id], onDelete: Cascade) - Recipient Recipient? @relation(fields: [recipientId], references: [id], onDelete: Cascade) + Document Document? @relation(fields: [documentId], references: [id], onDelete: Cascade) + Template Template? @relation(fields: [templateId], references: [id], onDelete: Cascade) + Recipient Recipient? @relation(fields: [recipientId], references: [id]) Signature Signature? @@index([documentId]) + @@index([templateId]) @@index([recipientId]) } @@ -254,3 +264,24 @@ model DocumentShareLink { @@unique([documentId, email]) } + +enum TemplateType { + PUBLIC + PRIVATE +} + +model Template { + id Int @id @default(autoincrement()) + userId Int + User User @relation(fields: [userId], references: [id], onDelete: Cascade) + title String + type TemplateType @default(PRIVATE) + templateDocumentDataId String + templateDocumentData DocumentData @relation(fields: [templateDocumentDataId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + Recipient Recipient[] + Field Field[] + + @@unique([templateDocumentDataId]) +} diff --git a/packages/trpc/server/router.ts b/packages/trpc/server/router.ts index bf8a03ce1..77d18e06d 100644 --- a/packages/trpc/server/router.ts +++ b/packages/trpc/server/router.ts @@ -6,6 +6,7 @@ import { profileRouter } from './profile-router/router'; import { recipientRouter } from './recipient-router/router'; import { shareLinkRouter } from './share-link-router/router'; import { singleplayerRouter } from './singleplayer-router/router'; +import { templateRouter } from './template-router/router'; import { router } from './trpc'; import { twoFactorAuthenticationRouter } from './two-factor-authentication-router/router'; @@ -19,6 +20,7 @@ export const appRouter = router({ shareLink: shareLinkRouter, singleplayer: singleplayerRouter, twoFactorAuthentication: twoFactorAuthenticationRouter, + template: templateRouter, }); export type AppRouter = typeof appRouter; diff --git a/packages/trpc/server/template-router/router.ts b/packages/trpc/server/template-router/router.ts new file mode 100644 index 000000000..e18f4cb4a --- /dev/null +++ b/packages/trpc/server/template-router/router.ts @@ -0,0 +1,94 @@ +import { TRPCError } from '@trpc/server'; + +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 { authenticatedProcedure, router } from '../trpc'; +import { + ZCreateDocumentFromTemplateMutationSchema, + ZCreateTemplateMutationSchema, + ZDeleteTemplateMutationSchema, + ZDuplicateTemplateMutationSchema, +} from './schema'; + +export const templateRouter = router({ + createTemplate: authenticatedProcedure + .input(ZCreateTemplateMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + const { title, templateDocumentDataId } = input; + + return await createTemplate({ + title, + userId: ctx.user.id, + templateDocumentDataId, + }); + } catch (err) { + console.error(err); + + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'We were unable to create this template. Please try again later.', + }); + } + }), + + createDocumentFromTemplate: authenticatedProcedure + .input(ZCreateDocumentFromTemplateMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + const { templateId } = input; + + return await createDocumentFromTemplate({ + templateId, + userId: ctx.user.id, + }); + } catch (err) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'We were unable to create this document. Please try again later.', + }); + } + }), + + duplicateTemplate: authenticatedProcedure + .input(ZDuplicateTemplateMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + const { templateId } = input; + + return await duplicateTemplate({ + templateId, + userId: ctx.user.id, + }); + } catch (err) { + console.error(err); + + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'We were unable to duplicate the template. Please try again later.', + }); + } + }), + + deleteTemplate: authenticatedProcedure + .input(ZDeleteTemplateMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + const { id } = input; + + const userId = ctx.user.id; + + return await deleteTemplate({ id, userId }); + } catch (err) { + console.error(err); + + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'We were unable to delete this template. Please try again later.', + }); + } + }), +}); diff --git a/packages/trpc/server/template-router/schema.ts b/packages/trpc/server/template-router/schema.ts new file mode 100644 index 000000000..bc7161f74 --- /dev/null +++ b/packages/trpc/server/template-router/schema.ts @@ -0,0 +1,26 @@ +import { z } from 'zod'; + +export const ZCreateTemplateMutationSchema = z.object({ + title: z.string().min(1), + templateDocumentDataId: z.string().min(1), +}); + +export const ZCreateDocumentFromTemplateMutationSchema = z.object({ + templateId: z.number(), +}); + +export const ZDuplicateTemplateMutationSchema = z.object({ + templateId: z.number(), +}); + +export const ZDeleteTemplateMutationSchema = z.object({ + id: z.number().min(1), +}); + +export type TCreateTemplateMutationSchema = z.infer; +export type TCreateDocumentFromTemplateMutationSchema = z.infer< + typeof ZCreateDocumentFromTemplateMutationSchema +>; + +export type TDuplicateTemplateMutationSchema = z.infer; +export type TDeleteTemplateMutationSchema = z.infer; diff --git a/packages/ui/primitives/document-dropzone.tsx b/packages/ui/primitives/document-dropzone.tsx index d81a3a7de..9ae4c2adb 100644 --- a/packages/ui/primitives/document-dropzone.tsx +++ b/packages/ui/primitives/document-dropzone.tsx @@ -75,10 +75,20 @@ const DocumentDropzoneCardCenterVariants: Variants = { }, }; +const DocumentDescription = { + document: { + headline: 'Add a document', + }, + template: { + headline: 'Upload Template Document', + }, +}; + export type DocumentDropzoneProps = { className?: string; disabled?: boolean; onDrop?: (_file: File) => void | Promise; + type?: 'document' | 'template'; [key: string]: unknown; }; @@ -86,6 +96,7 @@ export const DocumentDropzone = ({ className, onDrop, disabled, + type = 'document', ...props }: DocumentDropzoneProps) => { const { getRootProps, getInputProps } = useDropzone({ @@ -157,7 +168,7 @@ export const DocumentDropzone = ({

- Add a document + {DocumentDescription[type].headline}

Drag & drop your document here.

diff --git a/packages/ui/primitives/document-flow/types.ts b/packages/ui/primitives/document-flow/types.ts index 677dc931b..82f5706e6 100644 --- a/packages/ui/primitives/document-flow/types.ts +++ b/packages/ui/primitives/document-flow/types.ts @@ -24,7 +24,7 @@ export const ZDocumentFlowFormSchema = z.object({ formId: z.string().min(1), nativeId: z.number().optional(), type: z.nativeEnum(FieldType), - signerEmail: z.string().min(1), + signerEmail: z.string().min(1).optional(), pageNumber: z.number().min(1), pageX: z.number().min(0), pageY: z.number().min(0), diff --git a/packages/ui/primitives/template-flow/add-template-fields.tsx b/packages/ui/primitives/template-flow/add-template-fields.tsx new file mode 100644 index 000000000..138c73ea7 --- /dev/null +++ b/packages/ui/primitives/template-flow/add-template-fields.tsx @@ -0,0 +1,539 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { Caveat } from 'next/font/google'; + +import { ChevronsUpDown } from 'lucide-react'; +import { useFieldArray, useForm } from 'react-hook-form'; + +import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect'; +import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element'; +import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; +import { nanoid } from '@documenso/lib/universal/id'; +import { Field, FieldType, Recipient } from '@documenso/prisma/client'; +import { cn } from '@documenso/ui/lib/utils'; +import { Button } from '@documenso/ui/primitives/button'; +import { Card, CardContent } from '@documenso/ui/primitives/card'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, +} from '@documenso/ui/primitives/command'; +import { + DocumentFlowFormContainerActions, + DocumentFlowFormContainerContent, + DocumentFlowFormContainerFooter, + DocumentFlowFormContainerStep, +} from '@documenso/ui/primitives/document-flow/document-flow-root'; +import { FieldItem } from '@documenso/ui/primitives/document-flow/field-item'; +import { + DocumentFlowStep, + FRIENDLY_FIELD_TYPE, +} from '@documenso/ui/primitives/document-flow/types'; +import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover'; + +// import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip'; +import { TAddTemplateFieldsFormSchema } from './add-template-fields.types'; + +const fontCaveat = Caveat({ + weight: ['500'], + subsets: ['latin'], + display: 'swap', + variable: '--font-caveat', +}); + +const DEFAULT_HEIGHT_PERCENT = 5; +const DEFAULT_WIDTH_PERCENT = 15; + +const MIN_HEIGHT_PX = 60; +const MIN_WIDTH_PX = 200; + +export type AddTemplateFieldsFormProps = { + documentFlow: DocumentFlowStep; + hideRecipients?: boolean; + recipients: Recipient[]; + fields: Field[]; + numberOfSteps: number; + onSubmit: (_data: TAddTemplateFieldsFormSchema) => void; +}; + +export const AddTemplateFieldsFormPartial = ({ + documentFlow, + hideRecipients = false, + recipients, + fields, + numberOfSteps, + onSubmit, +}: AddTemplateFieldsFormProps) => { + const { isWithinPageBounds, getFieldPosition, getPage } = useDocumentElement(); + + const { + control, + handleSubmit, + formState: { isSubmitting }, + } = useForm({ + defaultValues: { + fields: fields.map((field) => ({ + nativeId: field.id, + formId: `${field.id}-${field.templateId}`, + pageNumber: field.page, + type: field.type, + pageX: Number(field.positionX), + pageY: Number(field.positionY), + pageWidth: Number(field.width), + pageHeight: Number(field.height), + signerId: field.recipientId ?? -1, + signerEmail: + recipients.find((recipient) => recipient.id === field.recipientId)?.email ?? '', + signerToken: + recipients.find((recipient) => recipient.id === field.recipientId)?.templateToken ?? '', + })), + }, + }); + + const onFormSubmit = handleSubmit(onSubmit); + + const { + append, + remove, + update, + fields: localFields, + } = useFieldArray({ + control, + name: 'fields', + }); + + const [selectedField, setSelectedField] = useState(null); + const [selectedSigner, setSelectedSigner] = useState(null); + const [showRecipientsSelector, setShowRecipientsSelector] = useState(false); + + const [isFieldWithinBounds, setIsFieldWithinBounds] = useState(false); + const [coords, setCoords] = useState({ + x: 0, + y: 0, + }); + + const fieldBounds = useRef({ + height: 0, + width: 0, + }); + + const onMouseMove = useCallback( + (event: MouseEvent) => { + setIsFieldWithinBounds( + isWithinPageBounds( + event, + PDF_VIEWER_PAGE_SELECTOR, + fieldBounds.current.width, + fieldBounds.current.height, + ), + ); + + setCoords({ + x: event.clientX - fieldBounds.current.width / 2, + y: event.clientY - fieldBounds.current.height / 2, + }); + }, + [isWithinPageBounds], + ); + + const onMouseClick = useCallback( + (event: MouseEvent) => { + if (!selectedField || !selectedSigner) { + return; + } + + const $page = getPage(event, PDF_VIEWER_PAGE_SELECTOR); + + if ( + !$page || + !isWithinPageBounds( + event, + PDF_VIEWER_PAGE_SELECTOR, + fieldBounds.current.width, + fieldBounds.current.height, + ) + ) { + setSelectedField(null); + return; + } + + const { top, left, height, width } = getBoundingClientRect($page); + + const pageNumber = parseInt($page.getAttribute('data-page-number') ?? '1', 10); + + // Calculate x and y as a percentage of the page width and height + let pageX = ((event.pageX - left) / width) * 100; + let pageY = ((event.pageY - top) / height) * 100; + + // Get the bounds as a percentage of the page width and height + const fieldPageWidth = (fieldBounds.current.width / width) * 100; + const fieldPageHeight = (fieldBounds.current.height / height) * 100; + + // And center it based on the bounds + pageX -= fieldPageWidth / 2; + pageY -= fieldPageHeight / 2; + + append({ + formId: nanoid(12), + type: selectedField, + pageNumber, + pageX, + pageY, + pageWidth: fieldPageWidth, + pageHeight: fieldPageHeight, + signerEmail: selectedSigner.email, + signerId: selectedSigner.id, + signerToken: selectedSigner.templateToken ?? '', + }); + + setIsFieldWithinBounds(false); + setSelectedField(null); + }, + [append, isWithinPageBounds, selectedField, selectedSigner, getPage], + ); + + const onFieldResize = useCallback( + (node: HTMLElement, index: number) => { + const field = localFields[index]; + + const $page = window.document.querySelector( + `${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`, + ); + + if (!$page) { + return; + } + + const { + x: pageX, + y: pageY, + width: pageWidth, + height: pageHeight, + } = getFieldPosition($page, node); + + update(index, { + ...field, + pageX, + pageY, + pageWidth, + pageHeight, + }); + }, + [getFieldPosition, localFields, update], + ); + + const onFieldMove = useCallback( + (node: HTMLElement, index: number) => { + const field = localFields[index]; + + const $page = window.document.querySelector( + `${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`, + ); + + if (!$page) { + return; + } + + const { x: pageX, y: pageY } = getFieldPosition($page, node); + + update(index, { + ...field, + pageX, + pageY, + }); + }, + [getFieldPosition, localFields, update], + ); + + useEffect(() => { + if (selectedField) { + window.addEventListener('mousemove', onMouseMove); + window.addEventListener('mouseup', onMouseClick); + } + + return () => { + window.removeEventListener('mousemove', onMouseMove); + window.removeEventListener('mouseup', onMouseClick); + }; + }, [onMouseClick, onMouseMove, selectedField]); + + useEffect(() => { + const observer = new MutationObserver((_mutations) => { + const $page = document.querySelector(PDF_VIEWER_PAGE_SELECTOR); + + if (!$page) { + return; + } + + const { height, width } = $page.getBoundingClientRect(); + + fieldBounds.current = { + height: Math.max(height * (DEFAULT_HEIGHT_PERCENT / 100), MIN_HEIGHT_PX), + width: Math.max(width * (DEFAULT_WIDTH_PERCENT / 100), MIN_WIDTH_PX), + }; + }); + + observer.observe(document.body, { + childList: true, + subtree: true, + }); + + return () => { + observer.disconnect(); + }; + }, []); + + useEffect(() => { + setSelectedSigner(recipients[0]); + }, [recipients]); + + return ( + <> + +
+ {selectedField && ( + + + {FRIENDLY_FIELD_TYPE[selectedField]} + + + )} + + {localFields.map((field, index) => ( + onFieldResize(options, index)} + onMove={(options) => onFieldMove(options, index)} + onRemove={() => remove(index)} + /> + ))} + + {!hideRecipients && ( + + + + + + + + + + + No recipient matching this description was found. + + + + + {recipients.map((recipient, index) => ( + { + setSelectedSigner(recipient); + setShowRecipientsSelector(false); + }} + > + {/* {recipient.sendStatus !== SendStatus.SENT ? ( + + ) : ( + + + + + + This document has already been sent to this recipient. You can no + longer edit this recipient. + + + )} */} + + {recipient.name && ( + + {recipient.name} ({recipient.email}) + + )} + + {!recipient.name && ( + + {recipient.email} + + )} + + ))} + + + + + )} + +
+
+ + + + + + + +
+
+
+
+ + + + + { + documentFlow.onBackStep?.(); + remove(); + }} + onGoNextClick={() => void onFormSubmit()} + /> + + + ); +}; diff --git a/packages/ui/primitives/template-flow/add-template-fields.types.ts b/packages/ui/primitives/template-flow/add-template-fields.types.ts new file mode 100644 index 000000000..4406f82a0 --- /dev/null +++ b/packages/ui/primitives/template-flow/add-template-fields.types.ts @@ -0,0 +1,23 @@ +import { z } from 'zod'; + +import { FieldType } from '@documenso/prisma/client'; + +export const ZAddTemplateFieldsFormSchema = z.object({ + fields: z.array( + z.object({ + formId: z.string().min(1), + nativeId: z.number().optional(), + type: z.nativeEnum(FieldType), + signerEmail: z.string().min(1), + signerToken: z.string(), + signerId: z.number().optional(), + pageNumber: z.number().min(1), + pageX: z.number().min(0), + pageY: z.number().min(0), + pageWidth: z.number().min(0), + pageHeight: z.number().min(0), + }), + ), +}); + +export type TAddTemplateFieldsFormSchema = z.infer; diff --git a/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx b/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx new file mode 100644 index 000000000..498314133 --- /dev/null +++ b/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx @@ -0,0 +1,205 @@ +'use client'; + +import React, { useId, useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { AnimatePresence, motion } from 'framer-motion'; +import { Plus, Trash } from 'lucide-react'; +import { Controller, useFieldArray, useForm } from 'react-hook-form'; + +import { nanoid } from '@documenso/lib/universal/id'; +import { Field, Recipient } from '@documenso/prisma/client'; +import { Button } from '@documenso/ui/primitives/button'; +import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message'; +import { Input } from '@documenso/ui/primitives/input'; +import { Label } from '@documenso/ui/primitives/label'; + +import { + DocumentFlowFormContainerActions, + DocumentFlowFormContainerContent, + DocumentFlowFormContainerFooter, + DocumentFlowFormContainerStep, +} from '../document-flow/document-flow-root'; +import { DocumentFlowStep } from '../document-flow/types'; +import { + TAddTemplatePlacholderRecipientsFormSchema, + ZAddTemplatePlacholderRecipientsFormSchema, +} from './add-template-placeholder-recipients.types'; + +export type AddTemplatePlaceholderRecipientsFormProps = { + documentFlow: DocumentFlowStep; + recipients: Recipient[]; + fields: Field[]; + numberOfSteps: number; + onSubmit: (_data: TAddTemplatePlacholderRecipientsFormSchema) => void; +}; + +export const AddTemplatePlaceholderRecipientsFormPartial = ({ + documentFlow, + numberOfSteps, + recipients, + fields: _fields, + onSubmit, +}: AddTemplatePlaceholderRecipientsFormProps) => { + const initialId = useId(); + const [placeholderRecipientCount, setPlaceholderRecipientCount] = useState(1); + + const { + control, + handleSubmit, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(ZAddTemplatePlacholderRecipientsFormSchema), + defaultValues: { + signers: + recipients.length > 0 + ? recipients.map((recipient) => ({ + nativeId: recipient.id, + formId: String(recipient.id), + name: recipient.name, + email: recipient.email, + })) + : [ + { + formId: initialId, + name: `John Doe`, + email: `johndoe@documenso.com`, + }, + ], + }, + }); + + const onFormSubmit = handleSubmit(onSubmit); + + const { + append: appendSigner, + fields: signers, + remove: removeSigner, + } = useFieldArray({ + control, + name: 'signers', + }); + + const onAddPlaceholderRecipient = () => { + setPlaceholderRecipientCount(placeholderRecipientCount + 1); + + appendSigner({ + formId: nanoid(12), + name: `John Doe ${placeholderRecipientCount}`, + email: `johndoe${placeholderRecipientCount}@documenso.com`, + }); + }; + + const onRemoveSigner = (index: number) => { + removeSigner(index); + }; + + const onKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter' && event.target instanceof HTMLInputElement) { + onAddPlaceholderRecipient(); + } + }; + + return ( + <> + +
+ + {signers.map((signer, index) => ( + +
+ + + ( + + )} + /> +
+ +
+ + + ( + + )} + /> +
+ +
+ +
+ +
+ + +
+
+ ))} +
+
+ + + +
+ +
+
+ + + + + void onFormSubmit()} + /> + + + ); +}; diff --git a/packages/ui/primitives/template-flow/add-template-placeholder-recipients.types.ts b/packages/ui/primitives/template-flow/add-template-placeholder-recipients.types.ts new file mode 100644 index 000000000..89c197f5e --- /dev/null +++ b/packages/ui/primitives/template-flow/add-template-placeholder-recipients.types.ts @@ -0,0 +1,26 @@ +import { z } from 'zod'; + +export const ZAddTemplatePlacholderRecipientsFormSchema = z + .object({ + signers: z.array( + z.object({ + formId: z.string().min(1), + nativeId: z.number().optional(), + email: z.string().min(1).email(), + name: z.string().optional(), + }), + ), + }) + .refine( + (schema) => { + const emails = schema.signers.map((signer) => signer.email.toLowerCase()); + + return new Set(emails).size === emails.length; + }, + // Dirty hack to handle errors when .root is populated for an array type + { message: 'Signers must have unique emails', path: ['signers__root'] }, + ); + +export type TAddTemplatePlacholderRecipientsFormSchema = z.infer< + typeof ZAddTemplatePlacholderRecipientsFormSchema +>; From 1eeb5fb103f81be2866177cc9aa9b4004b918e1c Mon Sep 17 00:00:00 2001 From: Mythie Date: Thu, 14 Dec 2023 15:28:27 +1100 Subject: [PATCH 2/8] fix: tidy code and base on main --- .../templates/[id]/edit-template.tsx | 27 ++++--- .../app/(dashboard)/templates/[id]/page.tsx | 2 +- .../templates/data-table-action-dropdown.tsx | 2 +- .../templates/data-table-templates.tsx | 4 +- .../templates/new-template-dialog.tsx | 39 ++++++---- .../src/app/(dashboard)/templates/page.tsx | 9 ++- .../(dashboard)/layout/desktop-nav.tsx | 37 +++++---- .../components/(dashboard)/layout/header.tsx | 2 +- .../add-template-fields.action.ts | 4 +- .../add-template-placeholders.action.ts | 4 +- packages/ui/primitives/dialog.tsx | 2 +- .../template-flow/add-template-fields.tsx | 22 +++--- .../add-template-placeholder-recipients.tsx | 78 ++++++++----------- 13 files changed, 121 insertions(+), 111 deletions(-) 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 b4d20b60d..920cac247 100644 --- a/apps/web/src/app/(dashboard)/templates/[id]/edit-template.tsx +++ b/apps/web/src/app/(dashboard)/templates/[id]/edit-template.tsx @@ -4,19 +4,20 @@ import { useState } from 'react'; import { useRouter } from 'next/navigation'; -import { DocumentData, Field, Recipient, Template, User } from '@documenso/prisma/client'; +import type { DocumentData, Field, Recipient, Template, User } from '@documenso/prisma/client'; import { cn } from '@documenso/ui/lib/utils'; import { Card, CardContent } from '@documenso/ui/primitives/card'; import { DocumentFlowFormContainer, DocumentFlowFormContainerHeader, } from '@documenso/ui/primitives/document-flow/document-flow-root'; -import { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types'; +import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types'; import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; +import { Stepper } from '@documenso/ui/primitives/stepper'; import { AddTemplateFieldsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-fields'; -import { TAddTemplateFieldsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-fields.types'; +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 { TAddTemplatePlacholderRecipientsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-placeholder-recipients.types'; +import type { TAddTemplatePlacholderRecipientsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-placeholder-recipients.types'; import { useToast } from '@documenso/ui/primitives/use-toast'; import { addTemplateFields } from '~/components/forms/edit-template/add-template-fields.action'; @@ -32,6 +33,7 @@ export type EditTemplateFormProps = { }; type EditTemplateStep = 'signers' | 'fields'; +const EditTemplateSteps: EditTemplateStep[] = ['signers', 'fields']; export const EditTemplateForm = ({ className, @@ -56,7 +58,6 @@ export const EditTemplateForm = ({ title: 'Add Fields', description: 'Add all relevant fields for each recipient.', stepIndex: 2, - onBackStep: () => setStep('signers'), }, }; @@ -118,33 +119,35 @@ export const EditTemplateForm = ({
- e.preventDefault()}> + e.preventDefault()} + > - {step === 'signers' && ( + setStep(EditTemplateSteps[step - 1])} + > - )} - {step === 'fields' && ( - )} +
diff --git a/apps/web/src/app/(dashboard)/templates/[id]/page.tsx b/apps/web/src/app/(dashboard)/templates/[id]/page.tsx index b8c645c80..15eaa6f3c 100644 --- a/apps/web/src/app/(dashboard)/templates/[id]/page.tsx +++ b/apps/web/src/app/(dashboard)/templates/[id]/page.tsx @@ -5,7 +5,7 @@ import { redirect } from 'next/navigation'; import { ChevronLeft } from 'lucide-react'; -import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session'; +import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; import { getFieldsForTemplate } from '@documenso/lib/server-only/field/get-fields-for-template'; import { getRecipientsForTemplate } from '@documenso/lib/server-only/recipient/get-recipients-for-template'; import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id'; diff --git a/apps/web/src/app/(dashboard)/templates/data-table-action-dropdown.tsx b/apps/web/src/app/(dashboard)/templates/data-table-action-dropdown.tsx index 15ad9b58b..9f26d632c 100644 --- a/apps/web/src/app/(dashboard)/templates/data-table-action-dropdown.tsx +++ b/apps/web/src/app/(dashboard)/templates/data-table-action-dropdown.tsx @@ -7,7 +7,7 @@ import Link from 'next/link'; import { Copy, Edit, MoreHorizontal, Trash2 } from 'lucide-react'; import { useSession } from 'next-auth/react'; -import { Template } from '@documenso/prisma/client'; +import type { Template } from '@documenso/prisma/client'; import { DropdownMenu, DropdownMenuContent, diff --git a/apps/web/src/app/(dashboard)/templates/data-table-templates.tsx b/apps/web/src/app/(dashboard)/templates/data-table-templates.tsx index 3cc8102e7..629204c2a 100644 --- a/apps/web/src/app/(dashboard)/templates/data-table-templates.tsx +++ b/apps/web/src/app/(dashboard)/templates/data-table-templates.tsx @@ -7,7 +7,7 @@ import { useRouter } from 'next/navigation'; import { Loader, Plus } from 'lucide-react'; import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; -import { Template } from '@documenso/prisma/client'; +import type { Template } from '@documenso/prisma/client'; import { trpc } from '@documenso/trpc/react'; import { Button } from '@documenso/ui/primitives/button'; import { DataTable } from '@documenso/ui/primitives/data-table'; @@ -109,7 +109,7 @@ export const TemplatesDataTable = ({ }} > {!isRowLoading && } - Use + Use Template diff --git a/apps/web/src/app/(dashboard)/templates/new-template-dialog.tsx b/apps/web/src/app/(dashboard)/templates/new-template-dialog.tsx index 7de1355a7..19a465001 100644 --- a/apps/web/src/app/(dashboard)/templates/new-template-dialog.tsx +++ b/apps/web/src/app/(dashboard)/templates/new-template-dialog.tsx @@ -1,11 +1,12 @@ 'use client'; -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; import { zodResolver } from '@hookform/resolvers/zod'; import { FilePlus, X } from 'lucide-react'; +import { useSession } from 'next-auth/react'; import { useForm } from 'react-hook-form'; import * as z from 'zod'; @@ -18,7 +19,6 @@ import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Dialog, DialogContent, - DialogDescription, DialogHeader, DialogTitle, DialogTrigger, @@ -45,7 +45,9 @@ type TCreateTemplateFormSchema = z.infer; export const NewTemplateDialog = () => { const router = useRouter(); + const { data: session } = useSession(); const { toast } = useToast(); + const form = useForm({ resolver: zodResolver(ZCreateTemplateFormSchema), defaultValues: { @@ -128,23 +130,29 @@ export const NewTemplateDialog = () => { setUploadedFile(null); }; + useEffect(() => { + if (!showNewTemplateDialog) { + form.reset(); + } + }, [form, showNewTemplateDialog]); + return ( - + New Template - + +
- + {
+
{uploadedFile ? ( -
resetForm()} - className="absolute right-2 top-2 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none disabled:pointer-events-none" + title="Remove Template" + className="text-muted-foreground absolute right-2.5 top-2.5 rounded-sm opacity-60 transition-opacity hover:opacity-100 focus:outline-none disabled:pointer-events-none" > - + Remove Template -
+
+

Uploaded Document

@@ -210,7 +221,7 @@ export const NewTemplateDialog = () => {
- +
); diff --git a/apps/web/src/app/(dashboard)/templates/page.tsx b/apps/web/src/app/(dashboard)/templates/page.tsx index bc6a90b12..f4167e42a 100644 --- a/apps/web/src/app/(dashboard)/templates/page.tsx +++ b/apps/web/src/app/(dashboard)/templates/page.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session'; +import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; import { getTemplates } from '@documenso/lib/server-only/template/get-templates'; import { TemplatesDataTable } from './data-table-templates'; @@ -27,9 +27,12 @@ export default async function TemplatesPage({ searchParams = {} }: TemplatesPage return (
-
+

Templates

- + +
+ +
diff --git a/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx b/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx index bb3384d0a..e04bc2818 100644 --- a/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx +++ b/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx @@ -41,9 +41,29 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => { return (
- - {navigationLinks.map(({ href, label }) => ( - - {label} - - ))}
); }; diff --git a/apps/web/src/components/(dashboard)/layout/header.tsx b/apps/web/src/components/(dashboard)/layout/header.tsx index 25f260575..cf8873a1a 100644 --- a/apps/web/src/components/(dashboard)/layout/header.tsx +++ b/apps/web/src/components/(dashboard)/layout/header.tsx @@ -49,7 +49,7 @@ export const Header = ({ className, user, ...props }: HeaderProps) => { -
+
{/* -
diff --git a/apps/web/src/app/(dashboard)/templates/duplicate-template-dialog.tsx b/apps/web/src/app/(dashboard)/templates/duplicate-template-dialog.tsx index 5c3118035..be743ff48 100644 --- a/apps/web/src/app/(dashboard)/templates/duplicate-template-dialog.tsx +++ b/apps/web/src/app/(dashboard)/templates/duplicate-template-dialog.tsx @@ -47,8 +47,6 @@ export const DuplicateTemplateDialog = ({ await duplicateTemplate({ templateId: id, }); - - router.refresh(); } catch (err) { toast({ title: 'Error', diff --git a/apps/web/src/app/(dashboard)/templates/new-template-dialog.tsx b/apps/web/src/app/(dashboard)/templates/new-template-dialog.tsx index 19a465001..a4aa9bce2 100644 --- a/apps/web/src/app/(dashboard)/templates/new-template-dialog.tsx +++ b/apps/web/src/app/(dashboard)/templates/new-template-dialog.tsx @@ -49,10 +49,10 @@ export const NewTemplateDialog = () => { const { toast } = useToast(); const form = useForm({ - resolver: zodResolver(ZCreateTemplateFormSchema), defaultValues: { name: '', }, + resolver: zodResolver(ZCreateTemplateFormSchema), }); const { mutateAsync: createTemplate, isLoading: isCreatingTemplate } = diff --git a/packages/lib/server-only/admin/get-recipients-stats.ts b/packages/lib/server-only/admin/get-recipients-stats.ts index b6663e988..07368b5a1 100644 --- a/packages/lib/server-only/admin/get-recipients-stats.ts +++ b/packages/lib/server-only/admin/get-recipients-stats.ts @@ -19,10 +19,11 @@ export const getRecipientsStats = async () => { results.forEach((result) => { const { readStatus, signingStatus, sendStatus, _count } = result; - stats[readStatus as keyof typeof stats] += _count; - stats.TOTAL_RECIPIENTS += _count; - stats[signingStatus as keyof typeof stats] += _count; - stats[sendStatus as keyof typeof stats] += _count; + + stats[readStatus] += _count; + stats[signingStatus] += _count; + stats[sendStatus] += _count; + stats.TOTAL_RECIPIENTS += _count; }); diff --git a/packages/lib/server-only/field/set-fields-for-document.ts b/packages/lib/server-only/field/set-fields-for-document.ts index 664be3b91..bd14d49b2 100644 --- a/packages/lib/server-only/field/set-fields-for-document.ts +++ b/packages/lib/server-only/field/set-fields-for-document.ts @@ -1,5 +1,6 @@ import { prisma } from '@documenso/prisma'; -import { FieldType, SendStatus, SigningStatus } from '@documenso/prisma/client'; +import type { FieldType } from '@documenso/prisma/client'; +import { SendStatus, SigningStatus } from '@documenso/prisma/client'; export interface SetFieldsForDocumentOptions { userId: number; diff --git a/packages/lib/server-only/field/set-fields-for-template.ts b/packages/lib/server-only/field/set-fields-for-template.ts index 6e2e39afc..9431666bf 100644 --- a/packages/lib/server-only/field/set-fields-for-template.ts +++ b/packages/lib/server-only/field/set-fields-for-template.ts @@ -1,5 +1,5 @@ import { prisma } from '@documenso/prisma'; -import { FieldType } from '@documenso/prisma/client'; +import type { FieldType } from '@documenso/prisma/client'; export type Field = { id?: number | null; @@ -32,7 +32,7 @@ export const setFieldsForTemplate = async ({ }); if (!template) { - throw new Error('Document not found'); + throw new Error('Template not found'); } const existingFields = await prisma.field.findMany({ @@ -93,8 +93,10 @@ export const setFieldsForTemplate = async ({ }, Recipient: { connect: { - id: field.signerId, - email: field.signerEmail, + templateId_email: { + templateId, + email: field.signerEmail.toLowerCase(), + }, }, }, }, diff --git a/packages/prisma/migrations/20231221070056_make_recipient_status_columns_non_null_again/migration.sql b/packages/prisma/migrations/20231221070056_make_recipient_status_columns_non_null_again/migration.sql new file mode 100644 index 000000000..d2ebc6405 --- /dev/null +++ b/packages/prisma/migrations/20231221070056_make_recipient_status_columns_non_null_again/migration.sql @@ -0,0 +1,12 @@ +/* + Warnings: + + - Made the column `readStatus` on table `Recipient` required. This step will fail if there are existing NULL values in that column. + - Made the column `signingStatus` on table `Recipient` required. This step will fail if there are existing NULL values in that column. + - Made the column `sendStatus` on table `Recipient` required. This step will fail if there are existing NULL values in that column. + +*/ +-- AlterTable +ALTER TABLE "Recipient" ALTER COLUMN "readStatus" SET NOT NULL, +ALTER COLUMN "signingStatus" SET NOT NULL, +ALTER COLUMN "sendStatus" SET NOT NULL; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index eb34ae903..67fb182a7 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -189,9 +189,9 @@ model Recipient { token String expired DateTime? signedAt DateTime? - readStatus ReadStatus? @default(NOT_OPENED) - signingStatus SigningStatus? @default(NOT_SIGNED) - sendStatus SendStatus? @default(NOT_SENT) + readStatus ReadStatus @default(NOT_OPENED) + signingStatus SigningStatus @default(NOT_SIGNED) + sendStatus SendStatus @default(NOT_SENT) Document Document? @relation(fields: [documentId], references: [id], onDelete: Cascade) Template Template? @relation(fields: [templateId], references: [id], onDelete: Cascade) Field Field[] From 972c20f906c1c1b56265e8428a6bd1188a0db4ff Mon Sep 17 00:00:00 2001 From: Mythie Date: Thu, 21 Dec 2023 21:20:37 +1100 Subject: [PATCH 7/8] chore: tidy migrations --- .../20231007013737_templates/migration.sql | 52 ------------- .../migration.sql | 15 ---- .../migration.sql | 14 ---- .../migration.sql | 8 -- .../migration.sql | 8 -- .../migration.sql | 9 --- .../migration.sql | 51 ------------- .../20231017042227_fix_typo/migration.sql | 23 ------ .../migration.sql | 8 -- .../migration.sql | 8 -- .../migration.sql | 5 -- .../migration.sql | 10 --- .../migration.sql | 54 -------------- .../migration.sql | 8 -- .../migration.sql | 12 --- .../migration.sql | 73 +++++++++++++++++++ 16 files changed, 73 insertions(+), 285 deletions(-) delete mode 100644 packages/prisma/migrations/20231007013737_templates/migration.sql delete mode 100644 packages/prisma/migrations/20231007014431_templates_type/migration.sql delete mode 100644 packages/prisma/migrations/20231007021427_reuse_document_data/migration.sql delete mode 100644 packages/prisma/migrations/20231007033447_remove_inserted_on_template_field/migration.sql delete mode 100644 packages/prisma/migrations/20231007080315_document_name_to_template/migration.sql delete mode 100644 packages/prisma/migrations/20231007211915_template_created_date/migration.sql delete mode 100644 packages/prisma/migrations/20231017041643_placeholder_recipients/migration.sql delete mode 100644 packages/prisma/migrations/20231017042227_fix_typo/migration.sql delete mode 100644 packages/prisma/migrations/20231019043226_template_recipient_email/migration.sql delete mode 100644 packages/prisma/migrations/20231020032507_template_recipient_token/migration.sql delete mode 100644 packages/prisma/migrations/20231021193915_template_token_for_recipient/migration.sql delete mode 100644 packages/prisma/migrations/20231030061522_recipient_name_not_placeholder/migration.sql delete mode 100644 packages/prisma/migrations/20231208090322_remove_template_specific_models/migration.sql delete mode 100644 packages/prisma/migrations/20231214081915_remove_template_token_column/migration.sql delete mode 100644 packages/prisma/migrations/20231221070056_make_recipient_status_columns_non_null_again/migration.sql create mode 100644 packages/prisma/migrations/20231221101005_add_templates/migration.sql diff --git a/packages/prisma/migrations/20231007013737_templates/migration.sql b/packages/prisma/migrations/20231007013737_templates/migration.sql deleted file mode 100644 index e0c1bf4ec..000000000 --- a/packages/prisma/migrations/20231007013737_templates/migration.sql +++ /dev/null @@ -1,52 +0,0 @@ --- CreateEnum -CREATE TYPE "TemplateStatus" AS ENUM ('PUBLIC', 'PRIVATE'); - --- CreateTable -CREATE TABLE "Template" ( - "id" SERIAL NOT NULL, - "userId" INTEGER NOT NULL, - "title" TEXT NOT NULL, - "description" TEXT, - "status" "TemplateStatus" NOT NULL DEFAULT 'PRIVATE', - "templateDataId" TEXT NOT NULL, - - CONSTRAINT "Template_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "TemplateData" ( - "id" TEXT NOT NULL, - "type" "DocumentDataType" NOT NULL, - "data" TEXT NOT NULL, - "initialData" TEXT NOT NULL, - - CONSTRAINT "TemplateData_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "TemplateField" ( - "id" SERIAL NOT NULL, - "templateId" INTEGER NOT NULL, - "type" "FieldType" NOT NULL, - "page" INTEGER NOT NULL, - "positionX" DECIMAL(65,30) NOT NULL DEFAULT 0, - "positionY" DECIMAL(65,30) NOT NULL DEFAULT 0, - "width" DECIMAL(65,30) NOT NULL DEFAULT -1, - "height" DECIMAL(65,30) NOT NULL DEFAULT -1, - "customText" TEXT NOT NULL, - "inserted" BOOLEAN NOT NULL, - - CONSTRAINT "TemplateField_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE UNIQUE INDEX "Template_templateDataId_key" ON "Template"("templateDataId"); - --- AddForeignKey -ALTER TABLE "Template" ADD CONSTRAINT "Template_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Template" ADD CONSTRAINT "Template_templateDataId_fkey" FOREIGN KEY ("templateDataId") REFERENCES "TemplateData"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "TemplateField" ADD CONSTRAINT "TemplateField_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "Template"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/prisma/migrations/20231007014431_templates_type/migration.sql b/packages/prisma/migrations/20231007014431_templates_type/migration.sql deleted file mode 100644 index c89e09a61..000000000 --- a/packages/prisma/migrations/20231007014431_templates_type/migration.sql +++ /dev/null @@ -1,15 +0,0 @@ -/* - Warnings: - - - The `status` column on the `Template` table would be dropped and recreated. This will lead to data loss if there is data in the column. - -*/ --- CreateEnum -CREATE TYPE "TemplateType" AS ENUM ('PUBLIC', 'PRIVATE'); - --- AlterTable -ALTER TABLE "Template" DROP COLUMN "status", -ADD COLUMN "status" "TemplateType" NOT NULL DEFAULT 'PRIVATE'; - --- DropEnum -DROP TYPE "TemplateStatus"; diff --git a/packages/prisma/migrations/20231007021427_reuse_document_data/migration.sql b/packages/prisma/migrations/20231007021427_reuse_document_data/migration.sql deleted file mode 100644 index 629f292fc..000000000 --- a/packages/prisma/migrations/20231007021427_reuse_document_data/migration.sql +++ /dev/null @@ -1,14 +0,0 @@ -/* - Warnings: - - - You are about to drop the `TemplateData` table. If the table is not empty, all the data it contains will be lost. - -*/ --- DropForeignKey -ALTER TABLE "Template" DROP CONSTRAINT "Template_templateDataId_fkey"; - --- DropTable -DROP TABLE "TemplateData"; - --- AddForeignKey -ALTER TABLE "Template" ADD CONSTRAINT "Template_templateDataId_fkey" FOREIGN KEY ("templateDataId") REFERENCES "DocumentData"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/prisma/migrations/20231007033447_remove_inserted_on_template_field/migration.sql b/packages/prisma/migrations/20231007033447_remove_inserted_on_template_field/migration.sql deleted file mode 100644 index 25ace4f72..000000000 --- a/packages/prisma/migrations/20231007033447_remove_inserted_on_template_field/migration.sql +++ /dev/null @@ -1,8 +0,0 @@ -/* - Warnings: - - - You are about to drop the column `inserted` on the `TemplateField` table. All the data in the column will be lost. - -*/ --- AlterTable -ALTER TABLE "TemplateField" DROP COLUMN "inserted"; diff --git a/packages/prisma/migrations/20231007080315_document_name_to_template/migration.sql b/packages/prisma/migrations/20231007080315_document_name_to_template/migration.sql deleted file mode 100644 index 45a52de3d..000000000 --- a/packages/prisma/migrations/20231007080315_document_name_to_template/migration.sql +++ /dev/null @@ -1,8 +0,0 @@ -/* - Warnings: - - - Added the required column `documentName` to the `Template` table without a default value. This is not possible if the table is not empty. - -*/ --- AlterTable -ALTER TABLE "Template" ADD COLUMN "documentName" TEXT NOT NULL; diff --git a/packages/prisma/migrations/20231007211915_template_created_date/migration.sql b/packages/prisma/migrations/20231007211915_template_created_date/migration.sql deleted file mode 100644 index 816da092e..000000000 --- a/packages/prisma/migrations/20231007211915_template_created_date/migration.sql +++ /dev/null @@ -1,9 +0,0 @@ -/* - Warnings: - - - Added the required column `updatedAt` to the `Template` table without a default value. This is not possible if the table is not empty. - -*/ --- AlterTable -ALTER TABLE "Template" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, -ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL; diff --git a/packages/prisma/migrations/20231017041643_placeholder_recipients/migration.sql b/packages/prisma/migrations/20231017041643_placeholder_recipients/migration.sql deleted file mode 100644 index b84a567c8..000000000 --- a/packages/prisma/migrations/20231017041643_placeholder_recipients/migration.sql +++ /dev/null @@ -1,51 +0,0 @@ -/* - Warnings: - - - You are about to drop the column `description` on the `Template` table. All the data in the column will be lost. - - You are about to drop the column `documentName` on the `Template` table. All the data in the column will be lost. - - You are about to drop the column `status` on the `Template` table. All the data in the column will be lost. - - You are about to drop the column `templateDataId` on the `Template` table. All the data in the column will be lost. - - A unique constraint covering the columns `[tempateDocumentDataId]` on the table `Template` will be added. If there are existing duplicate values, this will fail. - - Added the required column `tempateDocumentDataId` to the `Template` table without a default value. This is not possible if the table is not empty. - - Added the required column `inserted` to the `TemplateField` table without a default value. This is not possible if the table is not empty. - -*/ --- DropForeignKey -ALTER TABLE "Template" DROP CONSTRAINT "Template_templateDataId_fkey"; - --- DropIndex -DROP INDEX "Template_templateDataId_key"; - --- AlterTable -ALTER TABLE "Template" DROP COLUMN "description", -DROP COLUMN "documentName", -DROP COLUMN "status", -DROP COLUMN "templateDataId", -ADD COLUMN "tempateDocumentDataId" TEXT NOT NULL, -ADD COLUMN "type" "TemplateType" NOT NULL DEFAULT 'PRIVATE', -ALTER COLUMN "updatedAt" SET DEFAULT CURRENT_TIMESTAMP; - --- AlterTable -ALTER TABLE "TemplateField" ADD COLUMN "inserted" BOOLEAN NOT NULL, -ADD COLUMN "recipientId" INTEGER; - --- CreateTable -CREATE TABLE "TemplateRecipient" ( - "id" SERIAL NOT NULL, - "templateId" INTEGER NOT NULL, - "placeholder" VARCHAR(255) NOT NULL, - - CONSTRAINT "TemplateRecipient_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE UNIQUE INDEX "Template_tempateDocumentDataId_key" ON "Template"("tempateDocumentDataId"); - --- AddForeignKey -ALTER TABLE "Template" ADD CONSTRAINT "Template_tempateDocumentDataId_fkey" FOREIGN KEY ("tempateDocumentDataId") REFERENCES "DocumentData"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "TemplateRecipient" ADD CONSTRAINT "TemplateRecipient_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "Template"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "TemplateField" ADD CONSTRAINT "TemplateField_recipientId_fkey" FOREIGN KEY ("recipientId") REFERENCES "TemplateRecipient"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/prisma/migrations/20231017042227_fix_typo/migration.sql b/packages/prisma/migrations/20231017042227_fix_typo/migration.sql deleted file mode 100644 index ac9eaf10e..000000000 --- a/packages/prisma/migrations/20231017042227_fix_typo/migration.sql +++ /dev/null @@ -1,23 +0,0 @@ -/* - Warnings: - - - You are about to drop the column `tempateDocumentDataId` on the `Template` table. All the data in the column will be lost. - - A unique constraint covering the columns `[templateDocumentDataId]` on the table `Template` will be added. If there are existing duplicate values, this will fail. - - Added the required column `templateDocumentDataId` to the `Template` table without a default value. This is not possible if the table is not empty. - -*/ --- DropForeignKey -ALTER TABLE "Template" DROP CONSTRAINT "Template_tempateDocumentDataId_fkey"; - --- DropIndex -DROP INDEX "Template_tempateDocumentDataId_key"; - --- AlterTable -ALTER TABLE "Template" DROP COLUMN "tempateDocumentDataId", -ADD COLUMN "templateDocumentDataId" TEXT NOT NULL; - --- CreateIndex -CREATE UNIQUE INDEX "Template_templateDocumentDataId_key" ON "Template"("templateDocumentDataId"); - --- AddForeignKey -ALTER TABLE "Template" ADD CONSTRAINT "Template_templateDocumentDataId_fkey" FOREIGN KEY ("templateDocumentDataId") REFERENCES "DocumentData"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/prisma/migrations/20231019043226_template_recipient_email/migration.sql b/packages/prisma/migrations/20231019043226_template_recipient_email/migration.sql deleted file mode 100644 index 266333794..000000000 --- a/packages/prisma/migrations/20231019043226_template_recipient_email/migration.sql +++ /dev/null @@ -1,8 +0,0 @@ -/* - Warnings: - - - Added the required column `email` to the `TemplateRecipient` table without a default value. This is not possible if the table is not empty. - -*/ --- AlterTable -ALTER TABLE "TemplateRecipient" ADD COLUMN "email" VARCHAR(255) NOT NULL; diff --git a/packages/prisma/migrations/20231020032507_template_recipient_token/migration.sql b/packages/prisma/migrations/20231020032507_template_recipient_token/migration.sql deleted file mode 100644 index 9ce1fa70a..000000000 --- a/packages/prisma/migrations/20231020032507_template_recipient_token/migration.sql +++ /dev/null @@ -1,8 +0,0 @@ -/* - Warnings: - - - Added the required column `token` to the `TemplateRecipient` table without a default value. This is not possible if the table is not empty. - -*/ --- AlterTable -ALTER TABLE "TemplateRecipient" ADD COLUMN "token" TEXT NOT NULL; diff --git a/packages/prisma/migrations/20231021193915_template_token_for_recipient/migration.sql b/packages/prisma/migrations/20231021193915_template_token_for_recipient/migration.sql deleted file mode 100644 index a6b3e7199..000000000 --- a/packages/prisma/migrations/20231021193915_template_token_for_recipient/migration.sql +++ /dev/null @@ -1,5 +0,0 @@ --- AlterTable -ALTER TABLE "Recipient" ADD COLUMN "templateToken" TEXT; - --- AlterTable -ALTER TABLE "TemplateRecipient" ADD COLUMN "templateToken" TEXT; diff --git a/packages/prisma/migrations/20231030061522_recipient_name_not_placeholder/migration.sql b/packages/prisma/migrations/20231030061522_recipient_name_not_placeholder/migration.sql deleted file mode 100644 index 8b9275c68..000000000 --- a/packages/prisma/migrations/20231030061522_recipient_name_not_placeholder/migration.sql +++ /dev/null @@ -1,10 +0,0 @@ -/* - Warnings: - - - You are about to drop the column `placeholder` on the `TemplateRecipient` table. All the data in the column will be lost. - - Added the required column `name` to the `TemplateRecipient` table without a default value. This is not possible if the table is not empty. - -*/ --- AlterTable -ALTER TABLE "TemplateRecipient" DROP COLUMN "placeholder", -ADD COLUMN "name" VARCHAR(255) NOT NULL; diff --git a/packages/prisma/migrations/20231208090322_remove_template_specific_models/migration.sql b/packages/prisma/migrations/20231208090322_remove_template_specific_models/migration.sql deleted file mode 100644 index 7e2af3ef8..000000000 --- a/packages/prisma/migrations/20231208090322_remove_template_specific_models/migration.sql +++ /dev/null @@ -1,54 +0,0 @@ -/* - Warnings: - - - You are about to drop the `TemplateField` table. If the table is not empty, all the data it contains will be lost. - - You are about to drop the `TemplateRecipient` table. If the table is not empty, all the data it contains will be lost. - - A unique constraint covering the columns `[templateId,email]` on the table `Recipient` will be added. If there are existing duplicate values, this will fail. - -*/ --- DropForeignKey -ALTER TABLE "Field" DROP CONSTRAINT "Field_recipientId_fkey"; - --- DropForeignKey -ALTER TABLE "TemplateField" DROP CONSTRAINT "TemplateField_recipientId_fkey"; - --- DropForeignKey -ALTER TABLE "TemplateField" DROP CONSTRAINT "TemplateField_templateId_fkey"; - --- DropForeignKey -ALTER TABLE "TemplateRecipient" DROP CONSTRAINT "TemplateRecipient_templateId_fkey"; - --- AlterTable -ALTER TABLE "Field" ADD COLUMN "templateId" INTEGER, -ALTER COLUMN "documentId" DROP NOT NULL; - --- AlterTable -ALTER TABLE "Recipient" ADD COLUMN "templateId" INTEGER, -ALTER COLUMN "documentId" DROP NOT NULL, -ALTER COLUMN "readStatus" DROP NOT NULL, -ALTER COLUMN "signingStatus" DROP NOT NULL, -ALTER COLUMN "sendStatus" DROP NOT NULL; - --- DropTable -DROP TABLE "TemplateField"; - --- DropTable -DROP TABLE "TemplateRecipient"; - --- CreateIndex -CREATE INDEX "Field_templateId_idx" ON "Field"("templateId"); - --- CreateIndex -CREATE INDEX "Recipient_templateId_idx" ON "Recipient"("templateId"); - --- CreateIndex -CREATE UNIQUE INDEX "Recipient_templateId_email_key" ON "Recipient"("templateId", "email"); - --- AddForeignKey -ALTER TABLE "Recipient" ADD CONSTRAINT "Recipient_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "Template"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Field" ADD CONSTRAINT "Field_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "Template"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Field" ADD CONSTRAINT "Field_recipientId_fkey" FOREIGN KEY ("recipientId") REFERENCES "Recipient"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/packages/prisma/migrations/20231214081915_remove_template_token_column/migration.sql b/packages/prisma/migrations/20231214081915_remove_template_token_column/migration.sql deleted file mode 100644 index 8514a14b7..000000000 --- a/packages/prisma/migrations/20231214081915_remove_template_token_column/migration.sql +++ /dev/null @@ -1,8 +0,0 @@ -/* - Warnings: - - - You are about to drop the column `templateToken` on the `Recipient` table. All the data in the column will be lost. - -*/ --- AlterTable -ALTER TABLE "Recipient" DROP COLUMN "templateToken"; diff --git a/packages/prisma/migrations/20231221070056_make_recipient_status_columns_non_null_again/migration.sql b/packages/prisma/migrations/20231221070056_make_recipient_status_columns_non_null_again/migration.sql deleted file mode 100644 index d2ebc6405..000000000 --- a/packages/prisma/migrations/20231221070056_make_recipient_status_columns_non_null_again/migration.sql +++ /dev/null @@ -1,12 +0,0 @@ -/* - Warnings: - - - Made the column `readStatus` on table `Recipient` required. This step will fail if there are existing NULL values in that column. - - Made the column `signingStatus` on table `Recipient` required. This step will fail if there are existing NULL values in that column. - - Made the column `sendStatus` on table `Recipient` required. This step will fail if there are existing NULL values in that column. - -*/ --- AlterTable -ALTER TABLE "Recipient" ALTER COLUMN "readStatus" SET NOT NULL, -ALTER COLUMN "signingStatus" SET NOT NULL, -ALTER COLUMN "sendStatus" SET NOT NULL; diff --git a/packages/prisma/migrations/20231221101005_add_templates/migration.sql b/packages/prisma/migrations/20231221101005_add_templates/migration.sql new file mode 100644 index 000000000..21b0a2918 --- /dev/null +++ b/packages/prisma/migrations/20231221101005_add_templates/migration.sql @@ -0,0 +1,73 @@ +/* + Warnings: + + - A unique constraint covering the columns `[templateId,email]` on the table `Recipient` will be added. If there are existing duplicate values, this will fail. + +*/ +-- CreateEnum +CREATE TYPE "TemplateType" AS ENUM ('PUBLIC', 'PRIVATE'); + +-- DropForeignKey +ALTER TABLE "Field" DROP CONSTRAINT "Field_recipientId_fkey"; + +-- AlterTable +ALTER TABLE "Field" ADD COLUMN "templateId" INTEGER, +ALTER COLUMN "documentId" DROP NOT NULL; + +-- AlterTable +-- Add CHECK constraint to ensure that only one of the two columns is set +ALTER TABLE "Field" ADD CONSTRAINT "Field_templateId_documentId_check" CHECK ( + ("templateId" IS NULL AND "documentId" IS NOT NULL) OR + ("templateId" IS NOT NULL AND "documentId" IS NULL) +); + +-- AlterTable +ALTER TABLE "Recipient" ADD COLUMN "templateId" INTEGER, +ALTER COLUMN "documentId" DROP NOT NULL; + +-- AlterTable +-- Add CHECK constraint to ensure that only one of the two columns is set +ALTER TABLE "Recipient" ADD CONSTRAINT "Recipient_templateId_documentId_check" CHECK ( + ("templateId" IS NULL AND "documentId" IS NOT NULL) OR + ("templateId" IS NOT NULL AND "documentId" IS NULL) +); + +-- CreateTable +CREATE TABLE "Template" ( + "id" SERIAL NOT NULL, + "type" "TemplateType" NOT NULL DEFAULT 'PRIVATE', + "title" TEXT NOT NULL, + "userId" INTEGER NOT NULL, + "templateDocumentDataId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Template_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Template_templateDocumentDataId_key" ON "Template"("templateDocumentDataId"); + +-- CreateIndex +CREATE INDEX "Field_templateId_idx" ON "Field"("templateId"); + +-- CreateIndex +CREATE INDEX "Recipient_templateId_idx" ON "Recipient"("templateId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Recipient_templateId_email_key" ON "Recipient"("templateId", "email"); + +-- AddForeignKey +ALTER TABLE "Recipient" ADD CONSTRAINT "Recipient_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "Template"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Field" ADD CONSTRAINT "Field_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "Template"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Field" ADD CONSTRAINT "Field_recipientId_fkey" FOREIGN KEY ("recipientId") REFERENCES "Recipient"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Template" ADD CONSTRAINT "Template_templateDocumentDataId_fkey" FOREIGN KEY ("templateDocumentDataId") REFERENCES "DocumentData"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Template" ADD CONSTRAINT "Template_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; From 9ad94f986269d1fa85eb44dd6ae1c6e761e4d048 Mon Sep 17 00:00:00 2001 From: Mythie Date: Thu, 21 Dec 2023 21:37:33 +1100 Subject: [PATCH 8/8] fix: updates from review --- .../app/(dashboard)/documents/data-table-action-dropdown.tsx | 2 +- apps/web/src/app/(dashboard)/templates/[id]/page.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx b/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx index f1cbcc147..b8031b088 100644 --- a/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx +++ b/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx @@ -128,7 +128,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) = Download - + setDuplicateDialogOpen(true)}> Duplicate diff --git a/apps/web/src/app/(dashboard)/templates/[id]/page.tsx b/apps/web/src/app/(dashboard)/templates/[id]/page.tsx index 15eaa6f3c..6d234eff2 100644 --- a/apps/web/src/app/(dashboard)/templates/[id]/page.tsx +++ b/apps/web/src/app/(dashboard)/templates/[id]/page.tsx @@ -43,11 +43,11 @@ export default async function TemplatePage({ params }: TemplatePageProps) { const { templateDocumentData } = template; const [templateRecipients, templateFields] = await Promise.all([ - await getRecipientsForTemplate({ + getRecipientsForTemplate({ templateId, userId: user.id, }), - await getFieldsForTemplate({ + getFieldsForTemplate({ templateId, userId: user.id, }),