diff --git a/apps/web/package.json b/apps/web/package.json index c7db16aa6..d493b92d9 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -21,6 +21,7 @@ "framer-motion": "^10.12.8", "lucide-react": "^0.214.0", "micro": "^10.0.1", + "nanoid": "^4.0.2", "next": "13.4.1", "next-auth": "^4.22.1", "next-plausible": "^3.7.2", @@ -32,6 +33,7 @@ "react-hook-form": "^7.43.9", "react-icons": "^4.8.0", "react-pdf": "^7.1.1", + "react-rnd": "^10.4.1", "typescript": "5.0.4", "zod": "^3.21.4" }, diff --git a/apps/web/src/app/(dashboard)/documents/[id]/page.tsx b/apps/web/src/app/(dashboard)/documents/[id]/page.tsx index a83e2949a..7f3b76909 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/page.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/page.tsx @@ -5,6 +5,8 @@ import { ChevronLeft } from 'lucide-react'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session'; import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id'; +import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document'; +import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document'; import { EditDocumentForm } from '~/components/forms/edit-document'; @@ -34,6 +36,17 @@ export default async function DocumentPage({ params }: DocumentPageProps) { redirect('/documents'); } + const [recipients, fields] = await Promise.all([ + await getRecipientsForDocument({ + documentId, + userId: session.id, + }), + await getFieldsForDocument({ + documentId, + userId: session.id, + }), + ]); + return (
Add all relevant fields for each recipient. @@ -62,6 +322,7 @@ export const AddFieldsFormPartial = ({ ( setSelectedSigner(signer)}> - - + setSelectedField(FieldType.SIGNATURE)} + data-selected={selectedField === FieldType.SIGNATURE ? true : undefined} + > + @@ -128,15 +392,17 @@ export const AddFieldsFormPartial = ({ - - + setSelectedField(FieldType.EMAIL)} + data-selected={selectedField === FieldType.EMAIL ? true : undefined} + > + {'Email'} @@ -147,15 +413,17 @@ export const AddFieldsFormPartial = ({ - - + setSelectedField(FieldType.NAME)} + data-selected={selectedField === FieldType.NAME ? true : undefined} + > + {'Name'} @@ -166,15 +434,17 @@ export const AddFieldsFormPartial = ({ - - + setSelectedField(FieldType.DATE)} + data-selected={selectedField === FieldType.DATE ? true : undefined} + > + {'Date'} diff --git a/apps/web/src/components/forms/edit-document/add-signers.tsx b/apps/web/src/components/forms/edit-document/add-signers.tsx index 9292d6e3a..88427eee2 100644 --- a/apps/web/src/components/forms/edit-document/add-signers.tsx +++ b/apps/web/src/components/forms/edit-document/add-signers.tsx @@ -1,8 +1,11 @@ 'use client'; +import React from 'react'; + import { AnimatePresence, motion } from 'framer-motion'; import { Plus, Trash } from 'lucide-react'; -import { Control, Controller, FieldErrors, useFieldArray } from 'react-hook-form'; +import { nanoid } from 'nanoid'; +import { Control, Controller, FieldErrors, UseFormWatch, useFieldArray } from 'react-hook-form'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; @@ -16,6 +19,7 @@ import { TEditDocumentFormSchema } from './types'; export type AddSignersFormProps = { className?: string; control: Control; + watch: UseFormWatch; errors: FieldErrors; isSubmitting: boolean; }; @@ -27,14 +31,49 @@ export const AddSignersFormPartial = ({ isSubmitting, }: AddSignersFormProps) => { const { - append, + append: appendSigner, fields: signers, - remove, + remove: removeSigner, } = useFieldArray({ control, name: 'signers', }); + const { remove: removeField, fields: fields } = useFieldArray({ + name: 'fields', + control, + }); + + const onAddSigner = () => { + appendSigner({ + formId: nanoid(12), + name: '', + email: '', + }); + }; + + const onRemoveSigner = (index: number) => { + const signer = signers[index]; + + removeSigner(index); + + const fieldsToRemove: number[] = []; + + fields.forEach((field, fieldIndex) => { + if (field.signerEmail === signer.email) { + fieldsToRemove.push(fieldIndex); + } + }); + + removeField(fieldsToRemove); + }; + + const onKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter' && event.target instanceof HTMLInputElement) { + onAddSigner(); + } + }; + return ( Add Signers @@ -45,23 +84,27 @@ export const AddSignersFormPartial = ({ - + - {signers.map((field, index) => ( - + {signers.map((signer, index) => ( + - Email + + Email + * + ( )} @@ -69,17 +112,18 @@ export const AddSignersFormPartial = ({ - Name + Name ( )} @@ -89,9 +133,9 @@ export const AddSignersFormPartial = ({ remove(index)} + className="inline-flex h-10 w-10 items-center justify-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50" + disabled={isSubmitting || signers.length === 1} + onClick={() => onRemoveSigner(index)} > @@ -106,17 +150,10 @@ export const AddSignersFormPartial = ({ + + - - append({ - email: '', - name: '', - }) - } - > + onAddSigner()}> Add Signer diff --git a/apps/web/src/components/forms/edit-document/add-subject.tsx b/apps/web/src/components/forms/edit-document/add-subject.tsx new file mode 100644 index 000000000..2aedb0127 --- /dev/null +++ b/apps/web/src/components/forms/edit-document/add-subject.tsx @@ -0,0 +1,111 @@ +'use client'; + +import { Control, Controller, FieldErrors, UseFormWatch } from 'react-hook-form'; + +import { cn } from '@documenso/ui/lib/utils'; +import { Input } from '@documenso/ui/primitives/input'; +import { Label } from '@documenso/ui/primitives/label'; +import { Textarea } from '@documenso/ui/primitives/textarea'; + +import { FormErrorMessage } from '~/components/form/form-error-message'; + +import { TEditDocumentFormSchema } from './types'; + +export type AddSubjectFormProps = { + className?: string; + control: Control; + watch: UseFormWatch; + errors: FieldErrors; + isSubmitting: boolean; +}; + +export const AddSubjectFormPartial = ({ + className, + control, + errors, + isSubmitting, +}: AddSubjectFormProps) => { + return ( + + Add Subject + + + Add the subject and message you wish to send to signers. + + + + + + + + Subject (Optional) + + + ( + + )} + /> + + + + + + + Message (Optional) + + + ( + + )} + /> + + + + + + + You can use the following variables in your message: + + + + + + {'{signer.name}'} + {' '} + - The signer's name + + + + {'{signer.email}'} + {' '} + - The signer's email + + + + {'{document.name}'} + {' '} + - The document's name + + + + + + ); +}; diff --git a/apps/web/src/components/forms/edit-document/field-item.tsx b/apps/web/src/components/forms/edit-document/field-item.tsx new file mode 100644 index 000000000..0f6732a34 --- /dev/null +++ b/apps/web/src/components/forms/edit-document/field-item.tsx @@ -0,0 +1,149 @@ +import { useCallback, useEffect, useState } from 'react'; + +import { X } from 'lucide-react'; +import { createPortal } from 'react-dom'; +import { Rnd } from 'react-rnd'; + +import { cn } from '@documenso/ui/lib/utils'; +import { Card, CardContent } from '@documenso/ui/primitives/card'; + +import { PDF_VIEWER_PAGE_SELECTOR } from '~/components/(dashboard)/pdf-viewer/types'; + +import { FRIENDLY_FIELD_TYPE, TEditDocumentFormSchema } from './types'; + +type Field = TEditDocumentFormSchema['fields'][0]; + +export type FieldItemProps = { + field: Field; + passive?: boolean; + disabled?: boolean; + minHeight?: number; + minWidth?: number; + onResize?: (_node: HTMLElement) => void; + onMove?: (_node: HTMLElement) => void; + onRemove?: () => void; +}; + +export const FieldItem = ({ + field, + passive, + disabled, + minHeight, + minWidth, + onResize, + onMove, + onRemove, +}: FieldItemProps) => { + const [active, setActive] = useState(false); + const [coords, setCoords] = useState({ + pageX: 0, + pageY: 0, + pageHeight: 0, + pageWidth: 0, + }); + + const calculateCoords = useCallback(() => { + const $page = document.querySelector( + `${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`, + ); + + if (!$page) { + return; + } + + const { height, width } = $page.getBoundingClientRect(); + + const top = $page.offsetTop; + const left = $page.offsetLeft; + + // X and Y are percentages of the page's height and width + const pageX = (field.pageX / 100) * width + left; + const pageY = (field.pageY / 100) * height + top; + + const pageHeight = (field.pageHeight / 100) * height; + const pageWidth = (field.pageWidth / 100) * width; + + setCoords({ + pageX: pageX, + pageY: pageY, + pageHeight: pageHeight, + pageWidth: pageWidth, + }); + }, [field.pageHeight, field.pageNumber, field.pageWidth, field.pageX, field.pageY]); + + useEffect(() => { + calculateCoords(); + }, [calculateCoords]); + + useEffect(() => { + const onResize = () => { + calculateCoords(); + }; + + window.addEventListener('resize', onResize); + + return () => { + window.removeEventListener('resize', onResize); + }; + }, [calculateCoords]); + + return createPortal( + setActive(true)} + onResizeStart={() => setActive(true)} + onResizeStop={(_e, _d, ref) => { + setActive(false); + onResize?.(ref); + }} + onDragStop={(_e, d) => { + setActive(false); + onMove?.(d.node); + }} + > + {!disabled && ( + onRemove?.()} + > + + + )} + + + + {FRIENDLY_FIELD_TYPE[field.type]} + + + {field.signerEmail} + + + + , + document.body, + ); +}; diff --git a/apps/web/src/components/forms/edit-document/provider.tsx b/apps/web/src/components/forms/edit-document/provider.tsx new file mode 100644 index 000000000..ea5d7cd62 --- /dev/null +++ b/apps/web/src/components/forms/edit-document/provider.tsx @@ -0,0 +1,53 @@ +import React, { createContext, useRef } from 'react'; + +import { OnPDFViewerPageClick } from '~/components/(dashboard)/pdf-viewer/pdf-viewer'; + +type EditFormContextValue = { + firePageClickEvent: OnPDFViewerPageClick; + registerPageClickHandler: (_handler: OnPDFViewerPageClick) => void; + unregisterPageClickHandler: (_handler: OnPDFViewerPageClick) => void; +} | null; + +const EditFormContext = createContext(null); + +export type EditFormProviderProps = { + children: React.ReactNode; +}; + +export const useEditForm = () => { + const context = React.useContext(EditFormContext); + + if (!context) { + throw new Error('useEditForm must be used within a EditFormProvider'); + } + + return context; +}; + +export const EditFormProvider = ({ children }: EditFormProviderProps) => { + const handlers = useRef(new Set()); + + const firePageClickEvent: OnPDFViewerPageClick = (event) => { + handlers.current.forEach((handler) => handler(event)); + }; + + const registerPageClickHandler = (handler: OnPDFViewerPageClick) => { + handlers.current.add(handler); + }; + + const unregisterPageClickHandler = (handler: OnPDFViewerPageClick) => { + handlers.current.delete(handler); + }; + + return ( + + {children} + + ); +}; diff --git a/apps/web/src/components/forms/edit-document/types.ts b/apps/web/src/components/forms/edit-document/types.ts index 7ca551f48..a6b26654a 100644 --- a/apps/web/src/components/forms/edit-document/types.ts +++ b/apps/web/src/components/forms/edit-document/types.ts @@ -1,13 +1,49 @@ import { z } from 'zod'; +import { FieldType } from '@documenso/prisma/client'; + export const ZEditDocumentFormSchema = z.object({ - signers: z.array( + signers: z + .array( + z.object({ + formId: z.string().min(1), + nativeId: z.number().optional(), + email: z.string().min(1).email(), + name: z.string(), + }), + ) + .refine((signers) => { + const emails = signers.map((signer) => signer.email); + return new Set(emails).size === emails.length; + }, 'Signers must have unique emails'), + + fields: z.array( z.object({ - id: z.number().optional(), - email: z.string().min(1).email(), - name: z.string(), + formId: z.string().min(1), + nativeId: z.number().optional(), + type: z.nativeEnum(FieldType), + signerEmail: z.string().min(1), + 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), }), ), + + email: z.object({ + subject: z.string(), + message: z.string(), + }), }); export type TEditDocumentFormSchema = z.infer; + +export const FRIENDLY_FIELD_TYPE: Record = { + [FieldType.SIGNATURE]: 'Signature', + [FieldType.FREE_SIGNATURE]: 'Free Signature', + [FieldType.TEXT]: 'Text', + [FieldType.DATE]: 'Date', + [FieldType.EMAIL]: 'Email', + [FieldType.NAME]: 'Name', +}; diff --git a/package-lock.json b/package-lock.json index 06886b87d..bd3775a2f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -64,6 +64,7 @@ "framer-motion": "^10.12.8", "lucide-react": "^0.214.0", "micro": "^10.0.1", + "nanoid": "^4.0.2", "next": "13.4.1", "next-auth": "^4.22.1", "next-plausible": "^3.7.2", @@ -75,6 +76,7 @@ "react-hook-form": "^7.43.9", "react-icons": "^4.8.0", "react-pdf": "^7.1.1", + "react-rnd": "^10.4.1", "typescript": "5.0.4", "zod": "^3.21.4" }, @@ -85,6 +87,23 @@ "@types/react-dom": "18.2.4" } }, + "apps/web/node_modules/nanoid": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-4.0.2.tgz", + "integrity": "sha512-7ZtY5KTCNheRGfEFxnedV5zFiORN1+Y1N6zvPTnHQd8ENUvfaDBeuJDZb2bN/oXwXxu3qkTXDzy57W5vAmDTBw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^14 || ^16 || >=18" + } + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -4199,6 +4218,11 @@ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==" }, + "node_modules/fast-memoize": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/fast-memoize/-/fast-memoize-2.5.2.tgz", + "integrity": "sha512-Ue0LwpDYErFbmNnZSF0UH6eImUwDmogUO1jyE+JbN2gsQz/jICm1Ve7t9QT0rNSsfJt+Hs4/S3GnsDVjL4HVrw==" + }, "node_modules/fastq": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", @@ -6606,6 +6630,19 @@ "react": "^18.2.0" } }, + "node_modules/react-draggable": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.4.5.tgz", + "integrity": "sha512-OMHzJdyJbYTZo4uQE393fHcqqPYsEtkjfMgvCHr6rejT+Ezn4OZbNyGH50vv+SunC1RMvwOTSWkEODQLzw1M9g==", + "dependencies": { + "clsx": "^1.1.1", + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "react": ">= 16.3.0", + "react-dom": ">= 16.3.0" + } + }, "node_modules/react-dropzone": { "version": "14.2.3", "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.2.3.tgz", @@ -6717,6 +6754,37 @@ } } }, + "node_modules/react-rnd": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/react-rnd/-/react-rnd-10.4.1.tgz", + "integrity": "sha512-0m887AjQZr6p2ADLNnipquqsDq4XJu/uqVqI3zuoGD19tRm6uB83HmZWydtkilNp5EWsOHbLGF4IjWMdd5du8Q==", + "dependencies": { + "re-resizable": "6.9.6", + "react-draggable": "4.4.5", + "tslib": "2.3.1" + }, + "peerDependencies": { + "react": ">=16.3.0", + "react-dom": ">=16.3.0" + } + }, + "node_modules/react-rnd/node_modules/re-resizable": { + "version": "6.9.6", + "resolved": "https://registry.npmjs.org/re-resizable/-/re-resizable-6.9.6.tgz", + "integrity": "sha512-0xYKS5+Z0zk+vICQlcZW+g54CcJTTmHluA7JUUgvERDxnKAnytylcyPsA+BSFi759s5hPlHmBRegFrwXs2FuBQ==", + "dependencies": { + "fast-memoize": "^2.5.1" + }, + "peerDependencies": { + "react": "^16.13.1 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.13.1 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/react-rnd/node_modules/tslib": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", + "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==" + }, "node_modules/react-ssr-prepass": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/react-ssr-prepass/-/react-ssr-prepass-1.5.0.tgz", @@ -8342,6 +8410,7 @@ "@pdf-lib/fontkit": "^1.1.1", "@upstash/redis": "^1.20.6", "bcrypt": "^5.1.0", + "nanoid": "^4.0.2", "next": "13.4.1", "next-auth": "^4.22.1", "pdf-lib": "^1.17.1", @@ -8351,6 +8420,23 @@ "@types/bcrypt": "^5.0.0" } }, + "packages/lib/node_modules/nanoid": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-4.0.2.tgz", + "integrity": "sha512-7ZtY5KTCNheRGfEFxnedV5zFiORN1+Y1N6zvPTnHQd8ENUvfaDBeuJDZb2bN/oXwXxu3qkTXDzy57W5vAmDTBw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^14 || ^16 || >=18" + } + }, "packages/prettier-config": { "name": "@documenso/prettier-config", "version": "0.0.0", @@ -8390,6 +8476,8 @@ "version": "1.0.0", "license": "MIT", "dependencies": { + "@documenso/lib": "*", + "@documenso/prisma": "*", "@tanstack/react-query": "^4.29.5", "@trpc/client": "^10.25.1", "@trpc/next": "^10.25.1", @@ -8842,10 +8930,18 @@ "@types/bcrypt": "^5.0.0", "@upstash/redis": "^1.20.6", "bcrypt": "^5.1.0", + "nanoid": "^4.0.2", "next": "13.4.1", "next-auth": "^4.22.1", "pdf-lib": "^1.17.1", "stripe": "^12.7.0" + }, + "dependencies": { + "nanoid": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-4.0.2.tgz", + "integrity": "sha512-7ZtY5KTCNheRGfEFxnedV5zFiORN1+Y1N6zvPTnHQd8ENUvfaDBeuJDZb2bN/oXwXxu3qkTXDzy57W5vAmDTBw==" + } } }, "@documenso/marketing": { @@ -8903,6 +8999,8 @@ "@documenso/trpc": { "version": "file:packages/trpc", "requires": { + "@documenso/lib": "*", + "@documenso/prisma": "*", "@tanstack/react-query": "^4.29.5", "@trpc/client": "^10.25.1", "@trpc/next": "^10.25.1", @@ -8978,6 +9076,7 @@ "framer-motion": "^10.12.8", "lucide-react": "^0.214.0", "micro": "^10.0.1", + "nanoid": "^4.0.2", "next": "13.4.1", "next-auth": "^4.22.1", "next-plausible": "^3.7.2", @@ -8989,8 +9088,16 @@ "react-hook-form": "^7.43.9", "react-icons": "^4.8.0", "react-pdf": "^7.1.1", + "react-rnd": "^10.4.1", "typescript": "5.0.4", "zod": "^3.21.4" + }, + "dependencies": { + "nanoid": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-4.0.2.tgz", + "integrity": "sha512-7ZtY5KTCNheRGfEFxnedV5zFiORN1+Y1N6zvPTnHQd8ENUvfaDBeuJDZb2bN/oXwXxu3qkTXDzy57W5vAmDTBw==" + } } }, "@emotion/is-prop-valid": { @@ -11693,6 +11800,11 @@ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==" }, + "fast-memoize": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/fast-memoize/-/fast-memoize-2.5.2.tgz", + "integrity": "sha512-Ue0LwpDYErFbmNnZSF0UH6eImUwDmogUO1jyE+JbN2gsQz/jICm1Ve7t9QT0rNSsfJt+Hs4/S3GnsDVjL4HVrw==" + }, "fastq": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", @@ -13236,6 +13348,15 @@ "scheduler": "^0.23.0" } }, + "react-draggable": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.4.5.tgz", + "integrity": "sha512-OMHzJdyJbYTZo4uQE393fHcqqPYsEtkjfMgvCHr6rejT+Ezn4OZbNyGH50vv+SunC1RMvwOTSWkEODQLzw1M9g==", + "requires": { + "clsx": "^1.1.1", + "prop-types": "^15.8.1" + } + }, "react-dropzone": { "version": "14.2.3", "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.2.3.tgz", @@ -13299,6 +13420,31 @@ "tslib": "^2.0.0" } }, + "react-rnd": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/react-rnd/-/react-rnd-10.4.1.tgz", + "integrity": "sha512-0m887AjQZr6p2ADLNnipquqsDq4XJu/uqVqI3zuoGD19tRm6uB83HmZWydtkilNp5EWsOHbLGF4IjWMdd5du8Q==", + "requires": { + "re-resizable": "6.9.6", + "react-draggable": "4.4.5", + "tslib": "2.3.1" + }, + "dependencies": { + "re-resizable": { + "version": "6.9.6", + "resolved": "https://registry.npmjs.org/re-resizable/-/re-resizable-6.9.6.tgz", + "integrity": "sha512-0xYKS5+Z0zk+vICQlcZW+g54CcJTTmHluA7JUUgvERDxnKAnytylcyPsA+BSFi759s5hPlHmBRegFrwXs2FuBQ==", + "requires": { + "fast-memoize": "^2.5.1" + } + }, + "tslib": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", + "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==" + } + } + }, "react-ssr-prepass": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/react-ssr-prepass/-/react-ssr-prepass-1.5.0.tgz", diff --git a/packages/lib/next-auth/auth-options.ts b/packages/lib/next-auth/auth-options.ts index 4d3c9fa4d..531ef30f3 100644 --- a/packages/lib/next-auth/auth-options.ts +++ b/packages/lib/next-auth/auth-options.ts @@ -30,7 +30,6 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = { const user = await getUserByEmail({ email }).catch(() => null); if (!user || !user.password) { - console.log('no user'); return null; } diff --git a/packages/lib/package.json b/packages/lib/package.json index 202a37fe0..7e1ffc9db 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -19,6 +19,7 @@ "@upstash/redis": "^1.20.6", "bcrypt": "^5.1.0", "pdf-lib": "^1.17.1", + "nanoid": "^4.0.2", "next": "13.4.1", "next-auth": "^4.22.1", "stripe": "^12.7.0" diff --git a/packages/lib/server-only/field/get-fields-for-document.ts b/packages/lib/server-only/field/get-fields-for-document.ts new file mode 100644 index 000000000..1ced3dd12 --- /dev/null +++ b/packages/lib/server-only/field/get-fields-for-document.ts @@ -0,0 +1,19 @@ +import { prisma } from '@documenso/prisma'; + +export interface GetFieldsForDocumentOptions { + documentId: number; + userId: number; +} + +export const getFieldsForDocument = async ({ documentId, userId }: GetFieldsForDocumentOptions) => { + const fields = await prisma.field.findMany({ + where: { + documentId, + Document: { + userId, + }, + }, + }); + + return fields; +}; diff --git a/packages/lib/server-only/field/set-fields-for-document.ts b/packages/lib/server-only/field/set-fields-for-document.ts new file mode 100644 index 000000000..7c2d3d068 --- /dev/null +++ b/packages/lib/server-only/field/set-fields-for-document.ts @@ -0,0 +1,127 @@ +import { prisma } from '@documenso/prisma'; +import { SendStatus, SigningStatus } from '@documenso/prisma/client'; + +export interface SetFieldsForDocumentOptions { + userId: number; + documentId: number; + fields: { + id?: number | null; + signerEmail: string; + pageNumber: number; + pageX: number; + pageY: number; + pageWidth: number; + pageHeight: number; + }[]; +} + +export const setFieldsForDocument = async ({ + userId, + documentId, + fields, +}: SetFieldsForDocumentOptions) => { + const document = await prisma.document.findFirst({ + where: { + id: documentId, + userId, + }, + }); + + if (!document) { + throw new Error('Document not found'); + } + + const existingFields = await prisma.field.findMany({ + where: { + documentId, + }, + 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, + ...existing, + }; + }); + + for (const field of linkedFields) { + if ( + field.Recipient?.sendStatus === SendStatus.SENT || + field.Recipient?.signingStatus === SigningStatus.SIGNED + ) { + throw new Error('Cannot modify fields after sending'); + } + } + + const persistedFields = await prisma.$transaction( + linkedFields.map((field) => + field.id + ? prisma.field.update({ + where: { + id: field.id, + recipientId: field.recipientId, + documentId, + }, + data: { + type: field.type, + page: field.pageNumber, + positionX: field.pageX, + positionY: field.pageY, + width: field.pageWidth, + height: field.pageHeight, + }, + }) + : prisma.field.create({ + data: { + type: field.type!, + page: field.pageNumber, + positionX: field.pageX, + positionY: field.pageY, + width: field.pageWidth, + height: field.pageHeight, + customText: '', + inserted: false, + + Document: { + connect: { + id: document.id, + }, + }, + Recipient: { + connect: { + documentId_email: { + documentId: document.id, + 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/recipient/get-recipients-for-document.ts b/packages/lib/server-only/recipient/get-recipients-for-document.ts new file mode 100644 index 000000000..8b64bc1e8 --- /dev/null +++ b/packages/lib/server-only/recipient/get-recipients-for-document.ts @@ -0,0 +1,22 @@ +import { prisma } from '@documenso/prisma'; + +export interface GetRecipientsForDocumentOptions { + documentId: number; + userId: number; +} + +export const getRecipientsForDocument = async ({ + documentId, + userId, +}: GetRecipientsForDocumentOptions) => { + const recipients = await prisma.recipient.findMany({ + where: { + documentId, + Document: { + userId, + }, + }, + }); + + return recipients; +}; diff --git a/packages/lib/server-only/recipient/set-recipients-for-document.ts b/packages/lib/server-only/recipient/set-recipients-for-document.ts new file mode 100644 index 000000000..bee6acfc3 --- /dev/null +++ b/packages/lib/server-only/recipient/set-recipients-for-document.ts @@ -0,0 +1,103 @@ +import { nanoid } from 'nanoid'; + +import { prisma } from '@documenso/prisma'; +import { SendStatus, SigningStatus } from '@documenso/prisma/client'; + +export interface SetRecipientsForDocumentOptions { + userId: number; + documentId: number; + recipients: { + id?: number | null; + email: string; + name: string; + }[]; +} + +export const setRecipientsForDocument = async ({ + userId, + documentId, + recipients, +}: SetRecipientsForDocumentOptions) => { + const document = await prisma.document.findFirst({ + where: { + id: documentId, + userId, + }, + }); + + if (!document) { + throw new Error('Document not found'); + } + + const existingRecipients = await prisma.recipient.findMany({ + where: { + documentId, + }, + }); + + const removedRecipients = existingRecipients.filter( + (existingRecipient) => + !recipients.find( + (recipient) => + recipient.id === existingRecipient.id || recipient.email === existingRecipient.email, + ), + ); + + const linkedRecipients = recipients.map((recipient) => { + const existing = existingRecipients.find( + (existingRecipient) => + existingRecipient.id === recipient.id || existingRecipient.email === recipient.email, + ); + + return { + ...recipient, + ...existing, + }; + }); + + for (const recipient of linkedRecipients) { + if ( + recipient.sendStatus === SendStatus.SENT || + recipient.signingStatus === SigningStatus.SIGNED + ) { + throw new Error('Cannot modify recipients after sending'); + } + } + + const persistedRecipients = await prisma.$transaction( + linkedRecipients.map((recipient) => + recipient.id + ? prisma.recipient.update({ + where: { + id: recipient.id, + documentId, + }, + data: { + name: recipient.name, + email: recipient.email, + documentId, + }, + }) + : prisma.recipient.create({ + data: { + name: recipient.name, + email: recipient.email, + token: nanoid(), + documentId, + }, + }), + ), + ); + + if (removedRecipients.length > 0) { + await prisma.recipient.deleteMany({ + where: { + id: { + in: removedRecipients.map((recipient) => recipient.id), + }, + }, + }); + } + + return persistedRecipients; +}; diff --git a/packages/prisma/migrations/20230617040606_add_name_field/migration.sql b/packages/prisma/migrations/20230617040606_add_name_field/migration.sql new file mode 100644 index 000000000..a3412b6e6 --- /dev/null +++ b/packages/prisma/migrations/20230617040606_add_name_field/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "FieldType" ADD VALUE 'NAME'; diff --git a/packages/prisma/migrations/20230617041623_add_email_field/migration.sql b/packages/prisma/migrations/20230617041623_add_email_field/migration.sql new file mode 100644 index 000000000..5608f7974 --- /dev/null +++ b/packages/prisma/migrations/20230617041623_add_email_field/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "FieldType" ADD VALUE 'EMAIL'; diff --git a/packages/prisma/migrations/20230621130930_add_width_and_height_for_fields/migration.sql b/packages/prisma/migrations/20230621130930_add_width_and_height_for_fields/migration.sql new file mode 100644 index 000000000..eb3744e81 --- /dev/null +++ b/packages/prisma/migrations/20230621130930_add_width_and_height_for_fields/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "Field" ADD COLUMN "height" INTEGER NOT NULL DEFAULT -1, +ADD COLUMN "width" INTEGER NOT NULL DEFAULT -1; diff --git a/packages/prisma/migrations/20230621131348_add_document_id_and_email_index/migration.sql b/packages/prisma/migrations/20230621131348_add_document_id_and_email_index/migration.sql new file mode 100644 index 000000000..243b654e1 --- /dev/null +++ b/packages/prisma/migrations/20230621131348_add_document_id_and_email_index/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - A unique constraint covering the columns `[documentId,email]` on the table `Recipient` will be added. If there are existing duplicate values, this will fail. + +*/ +-- CreateIndex +CREATE UNIQUE INDEX "Recipient_documentId_email_key" ON "Recipient"("documentId", "email"); diff --git a/packages/prisma/migrations/20230621133446_migrate_field_position_to_float/migration.sql b/packages/prisma/migrations/20230621133446_migrate_field_position_to_float/migration.sql new file mode 100644 index 000000000..2aea39805 --- /dev/null +++ b/packages/prisma/migrations/20230621133446_migrate_field_position_to_float/migration.sql @@ -0,0 +1,9 @@ +-- AlterTable +ALTER TABLE "Field" ALTER COLUMN "positionX" SET DEFAULT 0, +ALTER COLUMN "positionX" SET DATA TYPE DECIMAL(65,30), +ALTER COLUMN "positionY" SET DEFAULT 0, +ALTER COLUMN "positionY" SET DATA TYPE DECIMAL(65,30), +ALTER COLUMN "height" SET DEFAULT -1, +ALTER COLUMN "height" SET DATA TYPE DECIMAL(65,30), +ALTER COLUMN "width" SET DEFAULT -1, +ALTER COLUMN "width" SET DATA TYPE DECIMAL(65,30); diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 1bc356428..4d5f900d7 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -1,5 +1,6 @@ generator client { provider = "prisma-client-js" + previewFeatures = ["extendedWhereUnique"] } datasource db { @@ -123,11 +124,15 @@ model Recipient { Document Document @relation(fields: [documentId], references: [id], onDelete: Cascade) Field Field[] Signature Signature[] + + @@unique([documentId, email]) } enum FieldType { SIGNATURE FREE_SIGNATURE + NAME + EMAIL DATE TEXT } @@ -138,8 +143,10 @@ model Field { recipientId Int? type FieldType page Int - positionX Int @default(0) - positionY Int @default(0) + positionX Decimal @default(0) + positionY Decimal @default(0) + width Decimal @default(-1) + height Decimal @default(-1) customText String inserted Boolean Document Document @relation(fields: [documentId], references: [id], onDelete: Cascade) diff --git a/packages/trpc/package.json b/packages/trpc/package.json index 839fefab9..1497a1ad5 100644 --- a/packages/trpc/package.json +++ b/packages/trpc/package.json @@ -7,6 +7,8 @@ "scripts": { }, "dependencies": { + "@documenso/lib": "*", + "@documenso/prisma": "*", "@tanstack/react-query": "^4.29.5", "@trpc/client": "^10.25.1", "@trpc/next": "^10.25.1", diff --git a/packages/trpc/server/document-router/router.ts b/packages/trpc/server/document-router/router.ts new file mode 100644 index 000000000..9d39d631b --- /dev/null +++ b/packages/trpc/server/document-router/router.ts @@ -0,0 +1,55 @@ +import { TRPCError } from '@trpc/server'; + +import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document'; +import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document'; + +import { authenticatedProcedure, router } from '../trpc'; +import { + ZSetFieldsForDocumentMutationSchema, + ZSetRecipientsForDocumentMutationSchema, +} from './schema'; + +export const documentRouter = router({ + setRecipientsForDocument: authenticatedProcedure + .input(ZSetRecipientsForDocumentMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + const { documentId, recipients } = input; + + return await setRecipientsForDocument({ + userId: ctx.user.id, + documentId, + recipients, + }); + } catch (err) { + console.error(err); + + throw new TRPCError({ + code: 'BAD_REQUEST', + message: + 'We were unable to set the recipients for this document. Please try again later.', + }); + } + }), + + setFieldsForDocument: authenticatedProcedure + .input(ZSetFieldsForDocumentMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + const { documentId, fields } = input; + + return await setFieldsForDocument({ + userId: ctx.user.id, + documentId, + fields, + }); + } catch (err) { + console.error(err); + + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'We were unable to set the fields for this document. Please try again later.', + }); + } + }), +}); diff --git a/packages/trpc/server/document-router/schema.ts b/packages/trpc/server/document-router/schema.ts new file mode 100644 index 000000000..1d6f7aa7c --- /dev/null +++ b/packages/trpc/server/document-router/schema.ts @@ -0,0 +1,38 @@ +import { z } from 'zod'; + +import { FieldType } from '@documenso/prisma/client'; + +export const ZSetRecipientsForDocumentMutationSchema = z.object({ + documentId: z.number(), + recipients: z.array( + z.object({ + id: z.number().nullish(), + email: z.string().min(1).email(), + name: z.string(), + }), + ), +}); + +export type TSetRecipientsForDocumentMutationSchema = z.infer< + typeof ZSetRecipientsForDocumentMutationSchema +>; + +export const ZSetFieldsForDocumentMutationSchema = z.object({ + documentId: z.number(), + fields: z.array( + z.object({ + id: z.number().nullish(), + type: z.nativeEnum(FieldType), + signerEmail: z.string().min(1), + 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 TSetFieldsForDocumentMutationSchema = z.infer< + typeof ZSetFieldsForDocumentMutationSchema +>; diff --git a/packages/trpc/server/router.ts b/packages/trpc/server/router.ts index d3f47b5c9..e83c5c192 100644 --- a/packages/trpc/server/router.ts +++ b/packages/trpc/server/router.ts @@ -1,4 +1,5 @@ import { authRouter } from './auth-router/router'; +import { documentRouter } from './document-router/router'; import { profileRouter } from './profile-router/router'; import { procedure, router } from './trpc'; @@ -6,6 +7,7 @@ export const appRouter = router({ hello: procedure.query(() => 'Hello, world!'), auth: authRouter, profile: profileRouter, + document: documentRouter, }); export type AppRouter = typeof appRouter; diff --git a/packages/ui/primitives/card.tsx b/packages/ui/primitives/card.tsx index 7030bb590..5144c4dfa 100644 --- a/packages/ui/primitives/card.tsx +++ b/packages/ui/primitives/card.tsx @@ -10,22 +10,10 @@ export type CardProps = React.HTMLAttributes & { spotlight?: boolean; gradient?: boolean; degrees?: number; - lightMode?: boolean; }; const Card = React.forwardRef( - ( - { - className, - children, - gradient = false, - spotlight = false, - degrees = 120, - lightMode = true, - ...props - }, - ref, - ) => { + ({ className, children, gradient = false, spotlight = false, degrees = 120, ...props }, ref) => { const mouseX = useMotionValue(0); const mouseY = useMotionValue(0); @@ -46,12 +34,15 @@ const Card = React.forwardRef( } as React.CSSProperties } className={cn( - 'bg-background text-foreground dark:hover:border-documenso group relative rounded-lg border-2 backdrop-blur-[2px]', + 'bg-background text-foreground group relative rounded-lg border-2 backdrop-blur-[2px]', { 'gradient-border-mask before:pointer-events-none before:absolute before:-inset-[2px] before:rounded-lg before:p-[2px] before:[background:linear-gradient(var(--card-gradient-degrees),theme(colors.documenso.DEFAULT/50%)_5%,theme(colors.border/80%)_30%)]': - gradient && lightMode, + gradient, + 'dark:gradient-border-mask before:pointer-events-none before:absolute before:-inset-[2px] before:rounded-lg before:p-[2px] before:[background:linear-gradient(var(--card-gradient-degrees),theme(colors.documenso.DEFAULT/70%)_5%,theme(colors.border/80%)_30%)]': + gradient, 'shadow-[0_0_0_4px_theme(colors.gray.100/70%),0_0_0_1px_theme(colors.gray.100/70%),0_0_0_0.5px_theme(colors.primary.DEFAULT/70%)]': - lightMode, + true, + 'dark:shadow-[0]': true, }, className, )}
@@ -128,15 +392,17 @@ export const AddFieldsFormPartial = ({
{'Email'} @@ -147,15 +413,17 @@ export const AddFieldsFormPartial = ({
{'Name'} @@ -166,15 +434,17 @@ export const AddFieldsFormPartial = ({
{'Date'} diff --git a/apps/web/src/components/forms/edit-document/add-signers.tsx b/apps/web/src/components/forms/edit-document/add-signers.tsx index 9292d6e3a..88427eee2 100644 --- a/apps/web/src/components/forms/edit-document/add-signers.tsx +++ b/apps/web/src/components/forms/edit-document/add-signers.tsx @@ -1,8 +1,11 @@ 'use client'; +import React from 'react'; + import { AnimatePresence, motion } from 'framer-motion'; import { Plus, Trash } from 'lucide-react'; -import { Control, Controller, FieldErrors, useFieldArray } from 'react-hook-form'; +import { nanoid } from 'nanoid'; +import { Control, Controller, FieldErrors, UseFormWatch, useFieldArray } from 'react-hook-form'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; @@ -16,6 +19,7 @@ import { TEditDocumentFormSchema } from './types'; export type AddSignersFormProps = { className?: string; control: Control; + watch: UseFormWatch; errors: FieldErrors; isSubmitting: boolean; }; @@ -27,14 +31,49 @@ export const AddSignersFormPartial = ({ isSubmitting, }: AddSignersFormProps) => { const { - append, + append: appendSigner, fields: signers, - remove, + remove: removeSigner, } = useFieldArray({ control, name: 'signers', }); + const { remove: removeField, fields: fields } = useFieldArray({ + name: 'fields', + control, + }); + + const onAddSigner = () => { + appendSigner({ + formId: nanoid(12), + name: '', + email: '', + }); + }; + + const onRemoveSigner = (index: number) => { + const signer = signers[index]; + + removeSigner(index); + + const fieldsToRemove: number[] = []; + + fields.forEach((field, fieldIndex) => { + if (field.signerEmail === signer.email) { + fieldsToRemove.push(fieldIndex); + } + }); + + removeField(fieldsToRemove); + }; + + const onKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter' && event.target instanceof HTMLInputElement) { + onAddSigner(); + } + }; + return ( Add Signers @@ -45,23 +84,27 @@ export const AddSignersFormPartial = ({ - + - {signers.map((field, index) => ( - + {signers.map((signer, index) => ( + - Email + + Email + * + ( )} @@ -69,17 +112,18 @@ export const AddSignersFormPartial = ({ - Name + Name ( )} @@ -89,9 +133,9 @@ export const AddSignersFormPartial = ({ remove(index)} + className="inline-flex h-10 w-10 items-center justify-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50" + disabled={isSubmitting || signers.length === 1} + onClick={() => onRemoveSigner(index)} > @@ -106,17 +150,10 @@ export const AddSignersFormPartial = ({ + + - - append({ - email: '', - name: '', - }) - } - > + onAddSigner()}> Add Signer diff --git a/apps/web/src/components/forms/edit-document/add-subject.tsx b/apps/web/src/components/forms/edit-document/add-subject.tsx new file mode 100644 index 000000000..2aedb0127 --- /dev/null +++ b/apps/web/src/components/forms/edit-document/add-subject.tsx @@ -0,0 +1,111 @@ +'use client'; + +import { Control, Controller, FieldErrors, UseFormWatch } from 'react-hook-form'; + +import { cn } from '@documenso/ui/lib/utils'; +import { Input } from '@documenso/ui/primitives/input'; +import { Label } from '@documenso/ui/primitives/label'; +import { Textarea } from '@documenso/ui/primitives/textarea'; + +import { FormErrorMessage } from '~/components/form/form-error-message'; + +import { TEditDocumentFormSchema } from './types'; + +export type AddSubjectFormProps = { + className?: string; + control: Control; + watch: UseFormWatch; + errors: FieldErrors; + isSubmitting: boolean; +}; + +export const AddSubjectFormPartial = ({ + className, + control, + errors, + isSubmitting, +}: AddSubjectFormProps) => { + return ( + + Add Subject + + + Add the subject and message you wish to send to signers. + + + + + + + + Subject (Optional) + + + ( + + )} + /> + + + + + + + Message (Optional) + + + ( + + )} + /> + + + + + + + You can use the following variables in your message: + + + + + + {'{signer.name}'} + {' '} + - The signer's name + + + + {'{signer.email}'} + {' '} + - The signer's email + + + + {'{document.name}'} + {' '} + - The document's name + + + + + + ); +}; diff --git a/apps/web/src/components/forms/edit-document/field-item.tsx b/apps/web/src/components/forms/edit-document/field-item.tsx new file mode 100644 index 000000000..0f6732a34 --- /dev/null +++ b/apps/web/src/components/forms/edit-document/field-item.tsx @@ -0,0 +1,149 @@ +import { useCallback, useEffect, useState } from 'react'; + +import { X } from 'lucide-react'; +import { createPortal } from 'react-dom'; +import { Rnd } from 'react-rnd'; + +import { cn } from '@documenso/ui/lib/utils'; +import { Card, CardContent } from '@documenso/ui/primitives/card'; + +import { PDF_VIEWER_PAGE_SELECTOR } from '~/components/(dashboard)/pdf-viewer/types'; + +import { FRIENDLY_FIELD_TYPE, TEditDocumentFormSchema } from './types'; + +type Field = TEditDocumentFormSchema['fields'][0]; + +export type FieldItemProps = { + field: Field; + passive?: boolean; + disabled?: boolean; + minHeight?: number; + minWidth?: number; + onResize?: (_node: HTMLElement) => void; + onMove?: (_node: HTMLElement) => void; + onRemove?: () => void; +}; + +export const FieldItem = ({ + field, + passive, + disabled, + minHeight, + minWidth, + onResize, + onMove, + onRemove, +}: FieldItemProps) => { + const [active, setActive] = useState(false); + const [coords, setCoords] = useState({ + pageX: 0, + pageY: 0, + pageHeight: 0, + pageWidth: 0, + }); + + const calculateCoords = useCallback(() => { + const $page = document.querySelector( + `${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`, + ); + + if (!$page) { + return; + } + + const { height, width } = $page.getBoundingClientRect(); + + const top = $page.offsetTop; + const left = $page.offsetLeft; + + // X and Y are percentages of the page's height and width + const pageX = (field.pageX / 100) * width + left; + const pageY = (field.pageY / 100) * height + top; + + const pageHeight = (field.pageHeight / 100) * height; + const pageWidth = (field.pageWidth / 100) * width; + + setCoords({ + pageX: pageX, + pageY: pageY, + pageHeight: pageHeight, + pageWidth: pageWidth, + }); + }, [field.pageHeight, field.pageNumber, field.pageWidth, field.pageX, field.pageY]); + + useEffect(() => { + calculateCoords(); + }, [calculateCoords]); + + useEffect(() => { + const onResize = () => { + calculateCoords(); + }; + + window.addEventListener('resize', onResize); + + return () => { + window.removeEventListener('resize', onResize); + }; + }, [calculateCoords]); + + return createPortal( + setActive(true)} + onResizeStart={() => setActive(true)} + onResizeStop={(_e, _d, ref) => { + setActive(false); + onResize?.(ref); + }} + onDragStop={(_e, d) => { + setActive(false); + onMove?.(d.node); + }} + > + {!disabled && ( + onRemove?.()} + > + + + )} + + + + {FRIENDLY_FIELD_TYPE[field.type]} + + + {field.signerEmail} + + + + , + document.body, + ); +}; diff --git a/apps/web/src/components/forms/edit-document/provider.tsx b/apps/web/src/components/forms/edit-document/provider.tsx new file mode 100644 index 000000000..ea5d7cd62 --- /dev/null +++ b/apps/web/src/components/forms/edit-document/provider.tsx @@ -0,0 +1,53 @@ +import React, { createContext, useRef } from 'react'; + +import { OnPDFViewerPageClick } from '~/components/(dashboard)/pdf-viewer/pdf-viewer'; + +type EditFormContextValue = { + firePageClickEvent: OnPDFViewerPageClick; + registerPageClickHandler: (_handler: OnPDFViewerPageClick) => void; + unregisterPageClickHandler: (_handler: OnPDFViewerPageClick) => void; +} | null; + +const EditFormContext = createContext(null); + +export type EditFormProviderProps = { + children: React.ReactNode; +}; + +export const useEditForm = () => { + const context = React.useContext(EditFormContext); + + if (!context) { + throw new Error('useEditForm must be used within a EditFormProvider'); + } + + return context; +}; + +export const EditFormProvider = ({ children }: EditFormProviderProps) => { + const handlers = useRef(new Set()); + + const firePageClickEvent: OnPDFViewerPageClick = (event) => { + handlers.current.forEach((handler) => handler(event)); + }; + + const registerPageClickHandler = (handler: OnPDFViewerPageClick) => { + handlers.current.add(handler); + }; + + const unregisterPageClickHandler = (handler: OnPDFViewerPageClick) => { + handlers.current.delete(handler); + }; + + return ( + + {children} + + ); +}; diff --git a/apps/web/src/components/forms/edit-document/types.ts b/apps/web/src/components/forms/edit-document/types.ts index 7ca551f48..a6b26654a 100644 --- a/apps/web/src/components/forms/edit-document/types.ts +++ b/apps/web/src/components/forms/edit-document/types.ts @@ -1,13 +1,49 @@ import { z } from 'zod'; +import { FieldType } from '@documenso/prisma/client'; + export const ZEditDocumentFormSchema = z.object({ - signers: z.array( + signers: z + .array( + z.object({ + formId: z.string().min(1), + nativeId: z.number().optional(), + email: z.string().min(1).email(), + name: z.string(), + }), + ) + .refine((signers) => { + const emails = signers.map((signer) => signer.email); + return new Set(emails).size === emails.length; + }, 'Signers must have unique emails'), + + fields: z.array( z.object({ - id: z.number().optional(), - email: z.string().min(1).email(), - name: z.string(), + formId: z.string().min(1), + nativeId: z.number().optional(), + type: z.nativeEnum(FieldType), + signerEmail: z.string().min(1), + 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), }), ), + + email: z.object({ + subject: z.string(), + message: z.string(), + }), }); export type TEditDocumentFormSchema = z.infer; + +export const FRIENDLY_FIELD_TYPE: Record = { + [FieldType.SIGNATURE]: 'Signature', + [FieldType.FREE_SIGNATURE]: 'Free Signature', + [FieldType.TEXT]: 'Text', + [FieldType.DATE]: 'Date', + [FieldType.EMAIL]: 'Email', + [FieldType.NAME]: 'Name', +}; diff --git a/package-lock.json b/package-lock.json index 06886b87d..bd3775a2f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -64,6 +64,7 @@ "framer-motion": "^10.12.8", "lucide-react": "^0.214.0", "micro": "^10.0.1", + "nanoid": "^4.0.2", "next": "13.4.1", "next-auth": "^4.22.1", "next-plausible": "^3.7.2", @@ -75,6 +76,7 @@ "react-hook-form": "^7.43.9", "react-icons": "^4.8.0", "react-pdf": "^7.1.1", + "react-rnd": "^10.4.1", "typescript": "5.0.4", "zod": "^3.21.4" }, @@ -85,6 +87,23 @@ "@types/react-dom": "18.2.4" } }, + "apps/web/node_modules/nanoid": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-4.0.2.tgz", + "integrity": "sha512-7ZtY5KTCNheRGfEFxnedV5zFiORN1+Y1N6zvPTnHQd8ENUvfaDBeuJDZb2bN/oXwXxu3qkTXDzy57W5vAmDTBw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^14 || ^16 || >=18" + } + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -4199,6 +4218,11 @@ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==" }, + "node_modules/fast-memoize": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/fast-memoize/-/fast-memoize-2.5.2.tgz", + "integrity": "sha512-Ue0LwpDYErFbmNnZSF0UH6eImUwDmogUO1jyE+JbN2gsQz/jICm1Ve7t9QT0rNSsfJt+Hs4/S3GnsDVjL4HVrw==" + }, "node_modules/fastq": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", @@ -6606,6 +6630,19 @@ "react": "^18.2.0" } }, + "node_modules/react-draggable": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.4.5.tgz", + "integrity": "sha512-OMHzJdyJbYTZo4uQE393fHcqqPYsEtkjfMgvCHr6rejT+Ezn4OZbNyGH50vv+SunC1RMvwOTSWkEODQLzw1M9g==", + "dependencies": { + "clsx": "^1.1.1", + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "react": ">= 16.3.0", + "react-dom": ">= 16.3.0" + } + }, "node_modules/react-dropzone": { "version": "14.2.3", "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.2.3.tgz", @@ -6717,6 +6754,37 @@ } } }, + "node_modules/react-rnd": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/react-rnd/-/react-rnd-10.4.1.tgz", + "integrity": "sha512-0m887AjQZr6p2ADLNnipquqsDq4XJu/uqVqI3zuoGD19tRm6uB83HmZWydtkilNp5EWsOHbLGF4IjWMdd5du8Q==", + "dependencies": { + "re-resizable": "6.9.6", + "react-draggable": "4.4.5", + "tslib": "2.3.1" + }, + "peerDependencies": { + "react": ">=16.3.0", + "react-dom": ">=16.3.0" + } + }, + "node_modules/react-rnd/node_modules/re-resizable": { + "version": "6.9.6", + "resolved": "https://registry.npmjs.org/re-resizable/-/re-resizable-6.9.6.tgz", + "integrity": "sha512-0xYKS5+Z0zk+vICQlcZW+g54CcJTTmHluA7JUUgvERDxnKAnytylcyPsA+BSFi759s5hPlHmBRegFrwXs2FuBQ==", + "dependencies": { + "fast-memoize": "^2.5.1" + }, + "peerDependencies": { + "react": "^16.13.1 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.13.1 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/react-rnd/node_modules/tslib": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", + "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==" + }, "node_modules/react-ssr-prepass": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/react-ssr-prepass/-/react-ssr-prepass-1.5.0.tgz", @@ -8342,6 +8410,7 @@ "@pdf-lib/fontkit": "^1.1.1", "@upstash/redis": "^1.20.6", "bcrypt": "^5.1.0", + "nanoid": "^4.0.2", "next": "13.4.1", "next-auth": "^4.22.1", "pdf-lib": "^1.17.1", @@ -8351,6 +8420,23 @@ "@types/bcrypt": "^5.0.0" } }, + "packages/lib/node_modules/nanoid": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-4.0.2.tgz", + "integrity": "sha512-7ZtY5KTCNheRGfEFxnedV5zFiORN1+Y1N6zvPTnHQd8ENUvfaDBeuJDZb2bN/oXwXxu3qkTXDzy57W5vAmDTBw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^14 || ^16 || >=18" + } + }, "packages/prettier-config": { "name": "@documenso/prettier-config", "version": "0.0.0", @@ -8390,6 +8476,8 @@ "version": "1.0.0", "license": "MIT", "dependencies": { + "@documenso/lib": "*", + "@documenso/prisma": "*", "@tanstack/react-query": "^4.29.5", "@trpc/client": "^10.25.1", "@trpc/next": "^10.25.1", @@ -8842,10 +8930,18 @@ "@types/bcrypt": "^5.0.0", "@upstash/redis": "^1.20.6", "bcrypt": "^5.1.0", + "nanoid": "^4.0.2", "next": "13.4.1", "next-auth": "^4.22.1", "pdf-lib": "^1.17.1", "stripe": "^12.7.0" + }, + "dependencies": { + "nanoid": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-4.0.2.tgz", + "integrity": "sha512-7ZtY5KTCNheRGfEFxnedV5zFiORN1+Y1N6zvPTnHQd8ENUvfaDBeuJDZb2bN/oXwXxu3qkTXDzy57W5vAmDTBw==" + } } }, "@documenso/marketing": { @@ -8903,6 +8999,8 @@ "@documenso/trpc": { "version": "file:packages/trpc", "requires": { + "@documenso/lib": "*", + "@documenso/prisma": "*", "@tanstack/react-query": "^4.29.5", "@trpc/client": "^10.25.1", "@trpc/next": "^10.25.1", @@ -8978,6 +9076,7 @@ "framer-motion": "^10.12.8", "lucide-react": "^0.214.0", "micro": "^10.0.1", + "nanoid": "^4.0.2", "next": "13.4.1", "next-auth": "^4.22.1", "next-plausible": "^3.7.2", @@ -8989,8 +9088,16 @@ "react-hook-form": "^7.43.9", "react-icons": "^4.8.0", "react-pdf": "^7.1.1", + "react-rnd": "^10.4.1", "typescript": "5.0.4", "zod": "^3.21.4" + }, + "dependencies": { + "nanoid": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-4.0.2.tgz", + "integrity": "sha512-7ZtY5KTCNheRGfEFxnedV5zFiORN1+Y1N6zvPTnHQd8ENUvfaDBeuJDZb2bN/oXwXxu3qkTXDzy57W5vAmDTBw==" + } } }, "@emotion/is-prop-valid": { @@ -11693,6 +11800,11 @@ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==" }, + "fast-memoize": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/fast-memoize/-/fast-memoize-2.5.2.tgz", + "integrity": "sha512-Ue0LwpDYErFbmNnZSF0UH6eImUwDmogUO1jyE+JbN2gsQz/jICm1Ve7t9QT0rNSsfJt+Hs4/S3GnsDVjL4HVrw==" + }, "fastq": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", @@ -13236,6 +13348,15 @@ "scheduler": "^0.23.0" } }, + "react-draggable": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.4.5.tgz", + "integrity": "sha512-OMHzJdyJbYTZo4uQE393fHcqqPYsEtkjfMgvCHr6rejT+Ezn4OZbNyGH50vv+SunC1RMvwOTSWkEODQLzw1M9g==", + "requires": { + "clsx": "^1.1.1", + "prop-types": "^15.8.1" + } + }, "react-dropzone": { "version": "14.2.3", "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.2.3.tgz", @@ -13299,6 +13420,31 @@ "tslib": "^2.0.0" } }, + "react-rnd": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/react-rnd/-/react-rnd-10.4.1.tgz", + "integrity": "sha512-0m887AjQZr6p2ADLNnipquqsDq4XJu/uqVqI3zuoGD19tRm6uB83HmZWydtkilNp5EWsOHbLGF4IjWMdd5du8Q==", + "requires": { + "re-resizable": "6.9.6", + "react-draggable": "4.4.5", + "tslib": "2.3.1" + }, + "dependencies": { + "re-resizable": { + "version": "6.9.6", + "resolved": "https://registry.npmjs.org/re-resizable/-/re-resizable-6.9.6.tgz", + "integrity": "sha512-0xYKS5+Z0zk+vICQlcZW+g54CcJTTmHluA7JUUgvERDxnKAnytylcyPsA+BSFi759s5hPlHmBRegFrwXs2FuBQ==", + "requires": { + "fast-memoize": "^2.5.1" + } + }, + "tslib": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", + "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==" + } + } + }, "react-ssr-prepass": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/react-ssr-prepass/-/react-ssr-prepass-1.5.0.tgz", diff --git a/packages/lib/next-auth/auth-options.ts b/packages/lib/next-auth/auth-options.ts index 4d3c9fa4d..531ef30f3 100644 --- a/packages/lib/next-auth/auth-options.ts +++ b/packages/lib/next-auth/auth-options.ts @@ -30,7 +30,6 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = { const user = await getUserByEmail({ email }).catch(() => null); if (!user || !user.password) { - console.log('no user'); return null; } diff --git a/packages/lib/package.json b/packages/lib/package.json index 202a37fe0..7e1ffc9db 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -19,6 +19,7 @@ "@upstash/redis": "^1.20.6", "bcrypt": "^5.1.0", "pdf-lib": "^1.17.1", + "nanoid": "^4.0.2", "next": "13.4.1", "next-auth": "^4.22.1", "stripe": "^12.7.0" diff --git a/packages/lib/server-only/field/get-fields-for-document.ts b/packages/lib/server-only/field/get-fields-for-document.ts new file mode 100644 index 000000000..1ced3dd12 --- /dev/null +++ b/packages/lib/server-only/field/get-fields-for-document.ts @@ -0,0 +1,19 @@ +import { prisma } from '@documenso/prisma'; + +export interface GetFieldsForDocumentOptions { + documentId: number; + userId: number; +} + +export const getFieldsForDocument = async ({ documentId, userId }: GetFieldsForDocumentOptions) => { + const fields = await prisma.field.findMany({ + where: { + documentId, + Document: { + userId, + }, + }, + }); + + return fields; +}; diff --git a/packages/lib/server-only/field/set-fields-for-document.ts b/packages/lib/server-only/field/set-fields-for-document.ts new file mode 100644 index 000000000..7c2d3d068 --- /dev/null +++ b/packages/lib/server-only/field/set-fields-for-document.ts @@ -0,0 +1,127 @@ +import { prisma } from '@documenso/prisma'; +import { SendStatus, SigningStatus } from '@documenso/prisma/client'; + +export interface SetFieldsForDocumentOptions { + userId: number; + documentId: number; + fields: { + id?: number | null; + signerEmail: string; + pageNumber: number; + pageX: number; + pageY: number; + pageWidth: number; + pageHeight: number; + }[]; +} + +export const setFieldsForDocument = async ({ + userId, + documentId, + fields, +}: SetFieldsForDocumentOptions) => { + const document = await prisma.document.findFirst({ + where: { + id: documentId, + userId, + }, + }); + + if (!document) { + throw new Error('Document not found'); + } + + const existingFields = await prisma.field.findMany({ + where: { + documentId, + }, + 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, + ...existing, + }; + }); + + for (const field of linkedFields) { + if ( + field.Recipient?.sendStatus === SendStatus.SENT || + field.Recipient?.signingStatus === SigningStatus.SIGNED + ) { + throw new Error('Cannot modify fields after sending'); + } + } + + const persistedFields = await prisma.$transaction( + linkedFields.map((field) => + field.id + ? prisma.field.update({ + where: { + id: field.id, + recipientId: field.recipientId, + documentId, + }, + data: { + type: field.type, + page: field.pageNumber, + positionX: field.pageX, + positionY: field.pageY, + width: field.pageWidth, + height: field.pageHeight, + }, + }) + : prisma.field.create({ + data: { + type: field.type!, + page: field.pageNumber, + positionX: field.pageX, + positionY: field.pageY, + width: field.pageWidth, + height: field.pageHeight, + customText: '', + inserted: false, + + Document: { + connect: { + id: document.id, + }, + }, + Recipient: { + connect: { + documentId_email: { + documentId: document.id, + 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/recipient/get-recipients-for-document.ts b/packages/lib/server-only/recipient/get-recipients-for-document.ts new file mode 100644 index 000000000..8b64bc1e8 --- /dev/null +++ b/packages/lib/server-only/recipient/get-recipients-for-document.ts @@ -0,0 +1,22 @@ +import { prisma } from '@documenso/prisma'; + +export interface GetRecipientsForDocumentOptions { + documentId: number; + userId: number; +} + +export const getRecipientsForDocument = async ({ + documentId, + userId, +}: GetRecipientsForDocumentOptions) => { + const recipients = await prisma.recipient.findMany({ + where: { + documentId, + Document: { + userId, + }, + }, + }); + + return recipients; +}; diff --git a/packages/lib/server-only/recipient/set-recipients-for-document.ts b/packages/lib/server-only/recipient/set-recipients-for-document.ts new file mode 100644 index 000000000..bee6acfc3 --- /dev/null +++ b/packages/lib/server-only/recipient/set-recipients-for-document.ts @@ -0,0 +1,103 @@ +import { nanoid } from 'nanoid'; + +import { prisma } from '@documenso/prisma'; +import { SendStatus, SigningStatus } from '@documenso/prisma/client'; + +export interface SetRecipientsForDocumentOptions { + userId: number; + documentId: number; + recipients: { + id?: number | null; + email: string; + name: string; + }[]; +} + +export const setRecipientsForDocument = async ({ + userId, + documentId, + recipients, +}: SetRecipientsForDocumentOptions) => { + const document = await prisma.document.findFirst({ + where: { + id: documentId, + userId, + }, + }); + + if (!document) { + throw new Error('Document not found'); + } + + const existingRecipients = await prisma.recipient.findMany({ + where: { + documentId, + }, + }); + + const removedRecipients = existingRecipients.filter( + (existingRecipient) => + !recipients.find( + (recipient) => + recipient.id === existingRecipient.id || recipient.email === existingRecipient.email, + ), + ); + + const linkedRecipients = recipients.map((recipient) => { + const existing = existingRecipients.find( + (existingRecipient) => + existingRecipient.id === recipient.id || existingRecipient.email === recipient.email, + ); + + return { + ...recipient, + ...existing, + }; + }); + + for (const recipient of linkedRecipients) { + if ( + recipient.sendStatus === SendStatus.SENT || + recipient.signingStatus === SigningStatus.SIGNED + ) { + throw new Error('Cannot modify recipients after sending'); + } + } + + const persistedRecipients = await prisma.$transaction( + linkedRecipients.map((recipient) => + recipient.id + ? prisma.recipient.update({ + where: { + id: recipient.id, + documentId, + }, + data: { + name: recipient.name, + email: recipient.email, + documentId, + }, + }) + : prisma.recipient.create({ + data: { + name: recipient.name, + email: recipient.email, + token: nanoid(), + documentId, + }, + }), + ), + ); + + if (removedRecipients.length > 0) { + await prisma.recipient.deleteMany({ + where: { + id: { + in: removedRecipients.map((recipient) => recipient.id), + }, + }, + }); + } + + return persistedRecipients; +}; diff --git a/packages/prisma/migrations/20230617040606_add_name_field/migration.sql b/packages/prisma/migrations/20230617040606_add_name_field/migration.sql new file mode 100644 index 000000000..a3412b6e6 --- /dev/null +++ b/packages/prisma/migrations/20230617040606_add_name_field/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "FieldType" ADD VALUE 'NAME'; diff --git a/packages/prisma/migrations/20230617041623_add_email_field/migration.sql b/packages/prisma/migrations/20230617041623_add_email_field/migration.sql new file mode 100644 index 000000000..5608f7974 --- /dev/null +++ b/packages/prisma/migrations/20230617041623_add_email_field/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "FieldType" ADD VALUE 'EMAIL'; diff --git a/packages/prisma/migrations/20230621130930_add_width_and_height_for_fields/migration.sql b/packages/prisma/migrations/20230621130930_add_width_and_height_for_fields/migration.sql new file mode 100644 index 000000000..eb3744e81 --- /dev/null +++ b/packages/prisma/migrations/20230621130930_add_width_and_height_for_fields/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "Field" ADD COLUMN "height" INTEGER NOT NULL DEFAULT -1, +ADD COLUMN "width" INTEGER NOT NULL DEFAULT -1; diff --git a/packages/prisma/migrations/20230621131348_add_document_id_and_email_index/migration.sql b/packages/prisma/migrations/20230621131348_add_document_id_and_email_index/migration.sql new file mode 100644 index 000000000..243b654e1 --- /dev/null +++ b/packages/prisma/migrations/20230621131348_add_document_id_and_email_index/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - A unique constraint covering the columns `[documentId,email]` on the table `Recipient` will be added. If there are existing duplicate values, this will fail. + +*/ +-- CreateIndex +CREATE UNIQUE INDEX "Recipient_documentId_email_key" ON "Recipient"("documentId", "email"); diff --git a/packages/prisma/migrations/20230621133446_migrate_field_position_to_float/migration.sql b/packages/prisma/migrations/20230621133446_migrate_field_position_to_float/migration.sql new file mode 100644 index 000000000..2aea39805 --- /dev/null +++ b/packages/prisma/migrations/20230621133446_migrate_field_position_to_float/migration.sql @@ -0,0 +1,9 @@ +-- AlterTable +ALTER TABLE "Field" ALTER COLUMN "positionX" SET DEFAULT 0, +ALTER COLUMN "positionX" SET DATA TYPE DECIMAL(65,30), +ALTER COLUMN "positionY" SET DEFAULT 0, +ALTER COLUMN "positionY" SET DATA TYPE DECIMAL(65,30), +ALTER COLUMN "height" SET DEFAULT -1, +ALTER COLUMN "height" SET DATA TYPE DECIMAL(65,30), +ALTER COLUMN "width" SET DEFAULT -1, +ALTER COLUMN "width" SET DATA TYPE DECIMAL(65,30); diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 1bc356428..4d5f900d7 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -1,5 +1,6 @@ generator client { provider = "prisma-client-js" + previewFeatures = ["extendedWhereUnique"] } datasource db { @@ -123,11 +124,15 @@ model Recipient { Document Document @relation(fields: [documentId], references: [id], onDelete: Cascade) Field Field[] Signature Signature[] + + @@unique([documentId, email]) } enum FieldType { SIGNATURE FREE_SIGNATURE + NAME + EMAIL DATE TEXT } @@ -138,8 +143,10 @@ model Field { recipientId Int? type FieldType page Int - positionX Int @default(0) - positionY Int @default(0) + positionX Decimal @default(0) + positionY Decimal @default(0) + width Decimal @default(-1) + height Decimal @default(-1) customText String inserted Boolean Document Document @relation(fields: [documentId], references: [id], onDelete: Cascade) diff --git a/packages/trpc/package.json b/packages/trpc/package.json index 839fefab9..1497a1ad5 100644 --- a/packages/trpc/package.json +++ b/packages/trpc/package.json @@ -7,6 +7,8 @@ "scripts": { }, "dependencies": { + "@documenso/lib": "*", + "@documenso/prisma": "*", "@tanstack/react-query": "^4.29.5", "@trpc/client": "^10.25.1", "@trpc/next": "^10.25.1", diff --git a/packages/trpc/server/document-router/router.ts b/packages/trpc/server/document-router/router.ts new file mode 100644 index 000000000..9d39d631b --- /dev/null +++ b/packages/trpc/server/document-router/router.ts @@ -0,0 +1,55 @@ +import { TRPCError } from '@trpc/server'; + +import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document'; +import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document'; + +import { authenticatedProcedure, router } from '../trpc'; +import { + ZSetFieldsForDocumentMutationSchema, + ZSetRecipientsForDocumentMutationSchema, +} from './schema'; + +export const documentRouter = router({ + setRecipientsForDocument: authenticatedProcedure + .input(ZSetRecipientsForDocumentMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + const { documentId, recipients } = input; + + return await setRecipientsForDocument({ + userId: ctx.user.id, + documentId, + recipients, + }); + } catch (err) { + console.error(err); + + throw new TRPCError({ + code: 'BAD_REQUEST', + message: + 'We were unable to set the recipients for this document. Please try again later.', + }); + } + }), + + setFieldsForDocument: authenticatedProcedure + .input(ZSetFieldsForDocumentMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + const { documentId, fields } = input; + + return await setFieldsForDocument({ + userId: ctx.user.id, + documentId, + fields, + }); + } catch (err) { + console.error(err); + + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'We were unable to set the fields for this document. Please try again later.', + }); + } + }), +}); diff --git a/packages/trpc/server/document-router/schema.ts b/packages/trpc/server/document-router/schema.ts new file mode 100644 index 000000000..1d6f7aa7c --- /dev/null +++ b/packages/trpc/server/document-router/schema.ts @@ -0,0 +1,38 @@ +import { z } from 'zod'; + +import { FieldType } from '@documenso/prisma/client'; + +export const ZSetRecipientsForDocumentMutationSchema = z.object({ + documentId: z.number(), + recipients: z.array( + z.object({ + id: z.number().nullish(), + email: z.string().min(1).email(), + name: z.string(), + }), + ), +}); + +export type TSetRecipientsForDocumentMutationSchema = z.infer< + typeof ZSetRecipientsForDocumentMutationSchema +>; + +export const ZSetFieldsForDocumentMutationSchema = z.object({ + documentId: z.number(), + fields: z.array( + z.object({ + id: z.number().nullish(), + type: z.nativeEnum(FieldType), + signerEmail: z.string().min(1), + 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 TSetFieldsForDocumentMutationSchema = z.infer< + typeof ZSetFieldsForDocumentMutationSchema +>; diff --git a/packages/trpc/server/router.ts b/packages/trpc/server/router.ts index d3f47b5c9..e83c5c192 100644 --- a/packages/trpc/server/router.ts +++ b/packages/trpc/server/router.ts @@ -1,4 +1,5 @@ import { authRouter } from './auth-router/router'; +import { documentRouter } from './document-router/router'; import { profileRouter } from './profile-router/router'; import { procedure, router } from './trpc'; @@ -6,6 +7,7 @@ export const appRouter = router({ hello: procedure.query(() => 'Hello, world!'), auth: authRouter, profile: profileRouter, + document: documentRouter, }); export type AppRouter = typeof appRouter; diff --git a/packages/ui/primitives/card.tsx b/packages/ui/primitives/card.tsx index 7030bb590..5144c4dfa 100644 --- a/packages/ui/primitives/card.tsx +++ b/packages/ui/primitives/card.tsx @@ -10,22 +10,10 @@ export type CardProps = React.HTMLAttributes & { spotlight?: boolean; gradient?: boolean; degrees?: number; - lightMode?: boolean; }; const Card = React.forwardRef( - ( - { - className, - children, - gradient = false, - spotlight = false, - degrees = 120, - lightMode = true, - ...props - }, - ref, - ) => { + ({ className, children, gradient = false, spotlight = false, degrees = 120, ...props }, ref) => { const mouseX = useMotionValue(0); const mouseY = useMotionValue(0); @@ -46,12 +34,15 @@ const Card = React.forwardRef( } as React.CSSProperties } className={cn( - 'bg-background text-foreground dark:hover:border-documenso group relative rounded-lg border-2 backdrop-blur-[2px]', + 'bg-background text-foreground group relative rounded-lg border-2 backdrop-blur-[2px]', { 'gradient-border-mask before:pointer-events-none before:absolute before:-inset-[2px] before:rounded-lg before:p-[2px] before:[background:linear-gradient(var(--card-gradient-degrees),theme(colors.documenso.DEFAULT/50%)_5%,theme(colors.border/80%)_30%)]': - gradient && lightMode, + gradient, + 'dark:gradient-border-mask before:pointer-events-none before:absolute before:-inset-[2px] before:rounded-lg before:p-[2px] before:[background:linear-gradient(var(--card-gradient-degrees),theme(colors.documenso.DEFAULT/70%)_5%,theme(colors.border/80%)_30%)]': + gradient, 'shadow-[0_0_0_4px_theme(colors.gray.100/70%),0_0_0_1px_theme(colors.gray.100/70%),0_0_0_0.5px_theme(colors.primary.DEFAULT/70%)]': - lightMode, + true, + 'dark:shadow-[0]': true, }, className, )}
+ Add the subject and message you wish to send to signers. +
+ You can use the following variables in your message: +
+ {'{signer.name}'} +
+ {'{signer.email}'} +
+ {'{document.name}'} +
+ {field.signerEmail} +