diff --git a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx index 953e7a6a1..ba9b806e5 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx @@ -8,6 +8,7 @@ import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id'; import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag'; +import { getCompletedFieldsForDocument } from '@documenso/lib/server-only/field/get-completed-fields-for-document'; import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document'; import { symmetricDecrypt } from '@documenso/lib/universal/crypto'; import { formatDocumentsPath } from '@documenso/lib/utils/teams'; @@ -20,6 +21,7 @@ import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip'; import { DocumentHistorySheet } from '~/components/document/document-history-sheet'; +import { DocumentReadOnlyFields } from '~/components/document/document-read-only-fields'; import { DocumentStatus as DocumentStatusComponent, FRIENDLY_STATUS_MAP, @@ -84,11 +86,16 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps) documentMeta.password = securePassword; } - const recipients = await getRecipientsForDocument({ - documentId, - teamId: team?.id, - userId: user.id, - }); + const [recipients, completedFields] = await Promise.all([ + getRecipientsForDocument({ + documentId, + teamId: team?.id, + userId: user.id, + }), + getCompletedFieldsForDocument({ + documentId, + }), + ]); const documentWithRecipients = { ...document, @@ -155,6 +162,13 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps) + {document.status === DocumentStatus.PENDING && ( + + )} +
diff --git a/apps/web/src/app/(dashboard)/documents/[id]/logs/document-logs-page-view.tsx b/apps/web/src/app/(dashboard)/documents/[id]/logs/document-logs-page-view.tsx index 2d786b9c9..0556fcd2d 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/logs/document-logs-page-view.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/logs/document-logs-page-view.tsx @@ -133,7 +133,11 @@ export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageVie
- +
diff --git a/apps/web/src/app/(dashboard)/documents/[id]/logs/download-certificate-button.tsx b/apps/web/src/app/(dashboard)/documents/[id]/logs/download-certificate-button.tsx index 49a330b94..1f2028358 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/logs/download-certificate-button.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/logs/download-certificate-button.tsx @@ -2,6 +2,7 @@ import { DownloadIcon } from 'lucide-react'; +import { DocumentStatus } from '@documenso/prisma/client'; import { trpc } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; @@ -10,11 +11,13 @@ import { useToast } from '@documenso/ui/primitives/use-toast'; export type DownloadCertificateButtonProps = { className?: string; documentId: number; + documentStatus: DocumentStatus; }; export const DownloadCertificateButton = ({ className, documentId, + documentStatus, }: DownloadCertificateButtonProps) => { const { toast } = useToast(); @@ -69,6 +72,7 @@ export const DownloadCertificateButton = ({ className={cn('w-full sm:w-auto', className)} loading={isLoading} variant="outline" + disabled={documentStatus !== DocumentStatus.COMPLETED} onClick={() => void onDownloadCertificatesClick()} > {!isLoading && } diff --git a/apps/web/src/app/(signing)/sign/[token]/page.tsx b/apps/web/src/app/(signing)/sign/[token]/page.tsx index bd46898af..b066193e6 100644 --- a/apps/web/src/app/(signing)/sign/[token]/page.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/page.tsx @@ -6,6 +6,7 @@ import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-c import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token'; import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized'; import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document'; +import { getCompletedFieldsForToken } from '@documenso/lib/server-only/field/get-completed-fields-for-token'; import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token'; import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token'; import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures'; @@ -37,7 +38,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp const requestMetadata = extractNextHeaderRequestMetadata(requestHeaders); - const [document, fields, recipient] = await Promise.all([ + const [document, fields, recipient, completedFields] = await Promise.all([ getDocumentAndSenderByToken({ token, userId: user?.id, @@ -45,6 +46,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp }).catch(() => null), getFieldsForToken({ token }), getRecipientByToken({ token }).catch(() => null), + getCompletedFieldsForToken({ token }), ]); if ( @@ -125,7 +127,12 @@ export default async function SigningPage({ params: { token } }: SigningPageProp signature={user?.email === recipient.email ? user.signature : undefined} > - + ); diff --git a/apps/web/src/app/(signing)/sign/[token]/signing-page-view.tsx b/apps/web/src/app/(signing)/sign/[token]/signing-page-view.tsx index c04679956..4691d0d4c 100644 --- a/apps/web/src/app/(signing)/sign/[token]/signing-page-view.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/signing-page-view.tsx @@ -4,12 +4,14 @@ import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-form import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones'; import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token'; +import type { CompletedField } from '@documenso/lib/types/fields'; import type { Field, Recipient } from '@documenso/prisma/client'; import { FieldType, RecipientRole } from '@documenso/prisma/client'; import { Card, CardContent } from '@documenso/ui/primitives/card'; import { ElementVisible } from '@documenso/ui/primitives/element-visible'; import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; +import { DocumentReadOnlyFields } from '~/components/document/document-read-only-fields'; import { truncateTitle } from '~/helpers/truncate-title'; import { DateField } from './date-field'; @@ -23,9 +25,15 @@ export type SigningPageViewProps = { document: DocumentAndSender; recipient: Recipient; fields: Field[]; + completedFields: CompletedField[]; }; -export const SigningPageView = ({ document, recipient, fields }: SigningPageViewProps) => { +export const SigningPageView = ({ + document, + recipient, + fields, + completedFields, +}: SigningPageViewProps) => { const truncatedTitle = truncateTitle(document.title); const { documentData, documentMeta } = document; @@ -70,6 +78,8 @@ export const SigningPageView = ({ document, recipient, fields }: SigningPageView
+ + {fields.map((field) => match(field.type) diff --git a/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-tooltip.tsx b/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-tooltip.tsx index 7a269d036..6bc8cf9af 100644 --- a/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-tooltip.tsx +++ b/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-tooltip.tsx @@ -1,12 +1,10 @@ 'use client'; -import { useRef, useState } from 'react'; - import { getRecipientType } from '@documenso/lib/client-only/recipient-type'; import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'; import type { DocumentStatus, Recipient } from '@documenso/prisma/client'; -import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover'; +import { PopoverHover } from '@documenso/ui/primitives/popover'; import { AvatarWithRecipient } from './avatar-with-recipient'; import { StackAvatar } from './stack-avatar'; @@ -25,11 +23,6 @@ export const StackAvatarsWithTooltip = ({ position, children, }: StackAvatarsWithTooltipProps) => { - const [open, setOpen] = useState(false); - - const isControlled = useRef(false); - const isMouseOverTimeout = useRef(null); - const waitingRecipients = recipients.filter( (recipient) => getRecipientType(recipient) === 'waiting', ); @@ -46,117 +39,74 @@ export const StackAvatarsWithTooltip = ({ (recipient) => getRecipientType(recipient) === 'unsigned', ); - const onMouseEnter = () => { - if (isMouseOverTimeout.current) { - clearTimeout(isMouseOverTimeout.current); - } - - if (isControlled.current) { - return; - } - - isMouseOverTimeout.current = setTimeout(() => { - setOpen((o) => (!o ? true : o)); - }, 200); - }; - - const onMouseLeave = () => { - if (isMouseOverTimeout.current) { - clearTimeout(isMouseOverTimeout.current); - } - - if (isControlled.current) { - return; - } - - setTimeout(() => { - setOpen((o) => (o ? false : o)); - }, 200); - }; - - const onOpenChange = (newOpen: boolean) => { - isControlled.current = newOpen; - - setOpen(newOpen); - }; - return ( - - - {children || } - - - - {completedRecipients.length > 0 && ( -
-

Completed

- {completedRecipients.map((recipient: Recipient) => ( -
- -
-

{recipient.email}

-

- {RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName} -

-
+ } + contentProps={{ + className: 'flex flex-col gap-y-5 py-2', + side: position, + }} + > + {completedRecipients.length > 0 && ( +
+

Completed

+ {completedRecipients.map((recipient: Recipient) => ( +
+ +
+

{recipient.email}

+

+ {RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName} +

- ))} -
- )} +
+ ))} +
+ )} - {waitingRecipients.length > 0 && ( -
-

Waiting

- {waitingRecipients.map((recipient: Recipient) => ( - - ))} -
- )} + {waitingRecipients.length > 0 && ( +
+

Waiting

+ {waitingRecipients.map((recipient: Recipient) => ( + + ))} +
+ )} - {openedRecipients.length > 0 && ( -
-

Opened

- {openedRecipients.map((recipient: Recipient) => ( - - ))} -
- )} + {openedRecipients.length > 0 && ( +
+

Opened

+ {openedRecipients.map((recipient: Recipient) => ( + + ))} +
+ )} - {uncompletedRecipients.length > 0 && ( -
-

Uncompleted

- {uncompletedRecipients.map((recipient: Recipient) => ( - - ))} -
- )} - - + {uncompletedRecipients.length > 0 && ( +
+

Uncompleted

+ {uncompletedRecipients.map((recipient: Recipient) => ( + + ))} +
+ )} + ); }; diff --git a/apps/web/src/components/document/document-read-only-fields.tsx b/apps/web/src/components/document/document-read-only-fields.tsx new file mode 100644 index 000000000..95a907d8f --- /dev/null +++ b/apps/web/src/components/document/document-read-only-fields.tsx @@ -0,0 +1,112 @@ +'use client'; + +import { useState } from 'react'; + +import { P, match } from 'ts-pattern'; + +import { + DEFAULT_DOCUMENT_DATE_FORMAT, + convertToLocalSystemFormat, +} from '@documenso/lib/constants/date-formats'; +import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; +import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones'; +import type { CompletedField } from '@documenso/lib/types/fields'; +import { extractInitials } from '@documenso/lib/utils/recipient-formatter'; +import type { DocumentMeta } from '@documenso/prisma/client'; +import { FieldType } from '@documenso/prisma/client'; +import { FieldRootContainer } from '@documenso/ui/components/field/field'; +import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar'; +import { Button } from '@documenso/ui/primitives/button'; +import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/types'; +import { ElementVisible } from '@documenso/ui/primitives/element-visible'; +import { PopoverHover } from '@documenso/ui/primitives/popover'; + +export type DocumentReadOnlyFieldsProps = { + fields: CompletedField[]; + documentMeta?: DocumentMeta; +}; + +export const DocumentReadOnlyFields = ({ documentMeta, fields }: DocumentReadOnlyFieldsProps) => { + const [hiddenFieldIds, setHiddenFieldIds] = useState>({}); + + const handleHideField = (fieldId: string) => { + setHiddenFieldIds((prev) => ({ ...prev, [fieldId]: true })); + }; + + return ( + + {fields.map( + (field) => + !hiddenFieldIds[field.secondaryId] && ( + +
+ + + {extractInitials(field.Recipient.name || field.Recipient.email)} + + + } + contentProps={{ + className: 'flex w-fit flex-col py-2.5 text-sm', + }} + > +

+ + {field.Recipient.name + ? `${field.Recipient.name} (${field.Recipient.email})` + : field.Recipient.email}{' '} + + inserted a {FRIENDLY_FIELD_TYPE[field.type].toLowerCase()} +

+ + +
+
+ +
+ {match(field) + .with({ type: FieldType.SIGNATURE }, (field) => + field.Signature?.signatureImageAsBase64 ? ( + Signature + ) : ( +

+ {field.Signature?.typedSignature} +

+ ), + ) + .with( + { type: P.union(FieldType.NAME, FieldType.TEXT, FieldType.EMAIL) }, + () => field.customText, + ) + .with({ type: FieldType.DATE }, () => + convertToLocalSystemFormat( + field.customText, + documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT, + documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE, + ), + ) + .with({ type: FieldType.FREE_SIGNATURE }, () => null) + .exhaustive()} +
+
+ ), + )} +
+ ); +}; diff --git a/package-lock.json b/package-lock.json index fb03b3a67..479463b25 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,7 @@ "eslint-config-custom": "*", "husky": "^9.0.11", "lint-staged": "^15.2.2", - "playwright": "^1.43.0", + "playwright": "1.43.0", "prettier": "^2.5.1", "rimraf": "^5.0.1", "turbo": "^1.9.3" @@ -4716,12 +4716,12 @@ } }, "node_modules/@playwright/test": { - "version": "1.40.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.40.0.tgz", - "integrity": "sha512-PdW+kn4eV99iP5gxWNSDQCbhMaDVej+RXL5xr6t04nbKLCBwYtA046t7ofoczHOm8u6c+45hpDKQVZqtqwkeQg==", + "version": "1.43.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.43.1.tgz", + "integrity": "sha512-HgtQzFgNEEo4TE22K/X7sYTYNqEMMTZmFS8kTq6m8hXj+m1D8TgwgIbumHddJa9h4yl4GkKb8/bgAl2+g7eDgA==", "dev": true, "dependencies": { - "playwright": "1.40.0" + "playwright": "1.43.1" }, "bin": { "playwright": "cli.js" @@ -4745,12 +4745,12 @@ } }, "node_modules/@playwright/test/node_modules/playwright": { - "version": "1.40.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.40.0.tgz", - "integrity": "sha512-gyHAgQjiDf1m34Xpwzaqb76KgfzYrhK7iih+2IzcOCoZWr/8ZqmdBw+t0RU85ZmfJMgtgAiNtBQ/KS2325INXw==", + "version": "1.43.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.43.1.tgz", + "integrity": "sha512-V7SoH0ai2kNt1Md9E3Gwas5B9m8KR2GVvwZnAI6Pg0m3sh7UvgiYhRrhsziCmqMJNouPckiOhk8T+9bSAK0VIA==", "dev": true, "dependencies": { - "playwright-core": "1.40.0" + "playwright-core": "1.43.1" }, "bin": { "playwright": "cli.js" @@ -4763,9 +4763,9 @@ } }, "node_modules/@playwright/test/node_modules/playwright-core": { - "version": "1.40.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.40.0.tgz", - "integrity": "sha512-fvKewVJpGeca8t0ipM56jkVSU6Eo0RmFvQ/MaCQNDYm+sdvKkMBBWTE1FdeMqIdumRaXXjZChWHvIzCGM/tA/Q==", + "version": "1.43.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.43.1.tgz", + "integrity": "sha512-EI36Mto2Vrx6VF7rm708qSnesVQKbxEWvPrfA1IPY6HgczBplDx7ENtx+K2n4kJ41sLLkuGfmb0ZLSSXlDhqPg==", "dev": true, "bin": { "playwright-core": "cli.js" @@ -24981,7 +24981,7 @@ "next-auth": "4.24.5", "oslo": "^0.17.0", "pdf-lib": "^1.17.1", - "playwright": "^1.43.0", + "playwright": "1.43.0", "react": "18.2.0", "remeda": "^1.27.1", "stripe": "^12.7.0", @@ -24989,7 +24989,7 @@ "zod": "^3.22.4" }, "devDependencies": { - "@playwright/browser-chromium": "^1.43.0", + "@playwright/browser-chromium": "1.43.0", "@types/luxon": "^3.3.1" } }, diff --git a/package.json b/package.json index 396b2ecfd..3480aae28 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "eslint-config-custom": "*", "husky": "^9.0.11", "lint-staged": "^15.2.2", - "playwright": "^1.43.0", + "playwright": "1.43.0", "prettier": "^2.5.1", "rimraf": "^5.0.1", "turbo": "^1.9.3" diff --git a/packages/lib/package.json b/packages/lib/package.json index 1aa7e431e..c6144df92 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -39,7 +39,7 @@ "next-auth": "4.24.5", "oslo": "^0.17.0", "pdf-lib": "^1.17.1", - "playwright": "^1.43.0", + "playwright": "1.43.0", "react": "18.2.0", "remeda": "^1.27.1", "stripe": "^12.7.0", @@ -48,6 +48,6 @@ }, "devDependencies": { "@types/luxon": "^3.3.1", - "@playwright/browser-chromium": "^1.43.0" + "@playwright/browser-chromium": "1.43.0" } } \ No newline at end of file diff --git a/packages/lib/server-only/document/send-completed-email.ts b/packages/lib/server-only/document/send-completed-email.ts index a397e47e7..f841aef33 100644 --- a/packages/lib/server-only/document/send-completed-email.ts +++ b/packages/lib/server-only/document/send-completed-email.ts @@ -80,7 +80,7 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo text: render(template, { plainText: true }), attachments: [ { - filename: document.title, + filename: document.title.endsWith('.pdf') ? document.title : document.title + '.pdf', content: Buffer.from(completedDocument), }, ], @@ -130,7 +130,7 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo text: render(template, { plainText: true }), attachments: [ { - filename: document.title, + filename: document.title.endsWith('.pdf') ? document.title : document.title + '.pdf', content: Buffer.from(completedDocument), }, ], diff --git a/packages/lib/server-only/field/get-completed-fields-for-document.ts b/packages/lib/server-only/field/get-completed-fields-for-document.ts new file mode 100644 index 000000000..304be95ba --- /dev/null +++ b/packages/lib/server-only/field/get-completed-fields-for-document.ts @@ -0,0 +1,29 @@ +import { prisma } from '@documenso/prisma'; +import { SigningStatus } from '@documenso/prisma/client'; + +export type GetCompletedFieldsForDocumentOptions = { + documentId: number; +}; + +export const getCompletedFieldsForDocument = async ({ + documentId, +}: GetCompletedFieldsForDocumentOptions) => { + return await prisma.field.findMany({ + where: { + documentId, + Recipient: { + signingStatus: SigningStatus.SIGNED, + }, + inserted: true, + }, + include: { + Signature: true, + Recipient: { + select: { + name: true, + email: true, + }, + }, + }, + }); +}; diff --git a/packages/lib/server-only/field/get-completed-fields-for-token.ts b/packages/lib/server-only/field/get-completed-fields-for-token.ts new file mode 100644 index 000000000..d84fa1343 --- /dev/null +++ b/packages/lib/server-only/field/get-completed-fields-for-token.ts @@ -0,0 +1,33 @@ +import { prisma } from '@documenso/prisma'; +import { SigningStatus } from '@documenso/prisma/client'; + +export type GetCompletedFieldsForTokenOptions = { + token: string; +}; + +export const getCompletedFieldsForToken = async ({ token }: GetCompletedFieldsForTokenOptions) => { + return await prisma.field.findMany({ + where: { + Document: { + Recipient: { + some: { + token, + }, + }, + }, + Recipient: { + signingStatus: SigningStatus.SIGNED, + }, + inserted: true, + }, + include: { + Signature: true, + Recipient: { + select: { + name: true, + email: true, + }, + }, + }, + }); +}; diff --git a/packages/lib/server-only/htmltopdf/get-certificate-pdf.ts b/packages/lib/server-only/htmltopdf/get-certificate-pdf.ts index a7182410e..dee40d41a 100644 --- a/packages/lib/server-only/htmltopdf/get-certificate-pdf.ts +++ b/packages/lib/server-only/htmltopdf/get-certificate-pdf.ts @@ -18,7 +18,9 @@ export const getCertificatePdf = async ({ documentId }: GetCertificatePdfOptions let browser: Browser; if (process.env.NEXT_PRIVATE_BROWSERLESS_URL) { - browser = await chromium.connect(process.env.NEXT_PRIVATE_BROWSERLESS_URL); + // !: Use CDP rather than the default `connect` method to avoid coupling to the playwright version. + // !: Previously we would have to keep the playwright version in sync with the browserless version to avoid errors. + browser = await chromium.connectOverCDP(process.env.NEXT_PRIVATE_BROWSERLESS_URL); } else { browser = await chromium.launch(); } diff --git a/packages/lib/server-only/template/create-document-from-template.ts b/packages/lib/server-only/template/create-document-from-template.ts index 2a83e17e9..88ac765e6 100644 --- a/packages/lib/server-only/template/create-document-from-template.ts +++ b/packages/lib/server-only/template/create-document-from-template.ts @@ -131,6 +131,10 @@ export const createDocumentFromTemplate = async ({ const documentRecipient = document.Recipient.find((doc) => doc.email === recipient?.email); + if (!documentRecipient) { + throw new Error('Recipient not found.'); + } + return { type: field.type, page: field.page, @@ -141,7 +145,7 @@ export const createDocumentFromTemplate = async ({ customText: field.customText, inserted: field.inserted, documentId: document.id, - recipientId: documentRecipient?.id || null, + recipientId: documentRecipient.id, }; }), }); diff --git a/packages/lib/server-only/template/duplicate-template.ts b/packages/lib/server-only/template/duplicate-template.ts index 97b3f0a0b..963d78bde 100644 --- a/packages/lib/server-only/template/duplicate-template.ts +++ b/packages/lib/server-only/template/duplicate-template.ts @@ -81,6 +81,10 @@ export const duplicateTemplate = async ({ (doc) => doc.email === recipient?.email, ); + if (!duplicatedTemplateRecipient) { + throw new Error('Recipient not found.'); + } + return { type: field.type, page: field.page, @@ -91,7 +95,7 @@ export const duplicateTemplate = async ({ customText: field.customText, inserted: field.inserted, templateId: duplicatedTemplate.id, - recipientId: duplicatedTemplateRecipient?.id || null, + recipientId: duplicatedTemplateRecipient.id, }; }), }); diff --git a/packages/lib/server-only/webhooks/get-webhooks-by-user-id.ts b/packages/lib/server-only/webhooks/get-webhooks-by-user-id.ts index 121fc670d..0877d878f 100644 --- a/packages/lib/server-only/webhooks/get-webhooks-by-user-id.ts +++ b/packages/lib/server-only/webhooks/get-webhooks-by-user-id.ts @@ -4,6 +4,7 @@ export const getWebhooksByUserId = async (userId: number) => { return await prisma.webhook.findMany({ where: { userId, + teamId: null, }, orderBy: { createdAt: 'desc', diff --git a/packages/lib/types/fields.ts b/packages/lib/types/fields.ts new file mode 100644 index 000000000..1b999310d --- /dev/null +++ b/packages/lib/types/fields.ts @@ -0,0 +1,3 @@ +import type { getCompletedFieldsForToken } from '../server-only/field/get-completed-fields-for-token'; + +export type CompletedField = Awaited>[number]; diff --git a/packages/prisma/migrations/20240418140819_remove_impossible_field_optional_states/migration.sql b/packages/prisma/migrations/20240418140819_remove_impossible_field_optional_states/migration.sql new file mode 100644 index 000000000..ee027d90e --- /dev/null +++ b/packages/prisma/migrations/20240418140819_remove_impossible_field_optional_states/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - Made the column `recipientId` on table `Field` required. This step will fail if there are existing NULL values in that column. + +*/ +-- Drop all Fields where the recipientId is null +DELETE FROM "Field" WHERE "recipientId" IS NULL; + +-- AlterTable +ALTER TABLE "Field" ALTER COLUMN "recipientId" SET NOT NULL; diff --git a/packages/prisma/migrations/20240424072655_update_foreign_key_constraints/migration.sql b/packages/prisma/migrations/20240424072655_update_foreign_key_constraints/migration.sql new file mode 100644 index 000000000..89c38943d --- /dev/null +++ b/packages/prisma/migrations/20240424072655_update_foreign_key_constraints/migration.sql @@ -0,0 +1,23 @@ +-- DropForeignKey +ALTER TABLE "PasswordResetToken" DROP CONSTRAINT "PasswordResetToken_userId_fkey"; + +-- DropForeignKey +ALTER TABLE "Signature" DROP CONSTRAINT "Signature_fieldId_fkey"; + +-- DropForeignKey +ALTER TABLE "Team" DROP CONSTRAINT "Team_ownerUserId_fkey"; + +-- DropForeignKey +ALTER TABLE "TeamMember" DROP CONSTRAINT "TeamMember_userId_fkey"; + +-- AddForeignKey +ALTER TABLE "PasswordResetToken" ADD CONSTRAINT "PasswordResetToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Signature" ADD CONSTRAINT "Signature_fieldId_fkey" FOREIGN KEY ("fieldId") REFERENCES "Field"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Team" ADD CONSTRAINT "Team_ownerUserId_fkey" FOREIGN KEY ("ownerUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TeamMember" ADD CONSTRAINT "TeamMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 8971f837f..8acfbedfa 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -98,7 +98,7 @@ model PasswordResetToken { createdAt DateTime @default(now()) expiry DateTime userId Int - User User @relation(fields: [userId], references: [id]) + User User @relation(fields: [userId], references: [id], onDelete: Cascade) } model Passkey { @@ -387,7 +387,7 @@ model Field { secondaryId String @unique @default(cuid()) documentId Int? templateId Int? - recipientId Int? + recipientId Int type FieldType page Int positionX Decimal @default(0) @@ -398,7 +398,7 @@ model Field { inserted Boolean 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], onDelete: Cascade) + Recipient Recipient @relation(fields: [recipientId], references: [id], onDelete: Cascade) Signature Signature? @@index([documentId]) @@ -415,7 +415,7 @@ model Signature { typedSignature String? Recipient Recipient @relation(fields: [recipientId], references: [id], onDelete: Cascade) - Field Field @relation(fields: [fieldId], references: [id], onDelete: Restrict) + Field Field @relation(fields: [fieldId], references: [id], onDelete: Cascade) @@index([recipientId]) } @@ -457,7 +457,7 @@ model Team { emailVerification TeamEmailVerification? transferVerification TeamTransferVerification? - owner User @relation(fields: [ownerUserId], references: [id]) + owner User @relation(fields: [ownerUserId], references: [id], onDelete: Cascade) subscription Subscription? document Document[] @@ -483,7 +483,7 @@ model TeamMember { createdAt DateTime @default(now()) role TeamMemberRole userId Int - user User @relation(fields: [userId], references: [id]) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) @@unique([userId, teamId]) @@ -564,5 +564,5 @@ model SiteSettings { data Json lastModifiedByUserId Int? lastModifiedAt DateTime @default(now()) - lastModifiedByUser User? @relation(fields: [lastModifiedByUserId], references: [id]) + lastModifiedByUser User? @relation(fields: [lastModifiedByUserId], references: [id], onDelete: SetNull) } diff --git a/packages/trpc/server/document-router/router.ts b/packages/trpc/server/document-router/router.ts index d12002674..64f3c2480 100644 --- a/packages/trpc/server/document-router/router.ts +++ b/packages/trpc/server/document-router/router.ts @@ -4,6 +4,7 @@ import { DateTime } from 'luxon'; import { getServerLimits } from '@documenso/ee/server-only/limits/server'; import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto'; +import { AppError } from '@documenso/lib/errors/app-error'; import { encryptSecondaryData } from '@documenso/lib/server-only/crypto/encrypt'; import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta'; import { createDocument } from '@documenso/lib/server-only/document/create-document'; @@ -20,6 +21,7 @@ import { updateDocumentSettings } from '@documenso/lib/server-only/document/upda import { updateTitle } from '@documenso/lib/server-only/document/update-title'; import { symmetricEncrypt } from '@documenso/lib/universal/crypto'; import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; +import { DocumentStatus } from '@documenso/prisma/client'; import { authenticatedProcedure, procedure, router } from '../trpc'; import { @@ -413,6 +415,10 @@ export const documentRouter = router({ teamId, }); + if (document.status !== DocumentStatus.COMPLETED) { + throw new AppError('DOCUMENT_NOT_COMPLETE'); + } + const encrypted = encryptSecondaryData({ data: document.id.toString(), expiresAt: DateTime.now().plus({ minutes: 5 }).toJSDate().valueOf(), diff --git a/packages/ui/components/field/field.tsx b/packages/ui/components/field/field.tsx index e40b2e3d9..ce62443de 100644 --- a/packages/ui/components/field/field.tsx +++ b/packages/ui/components/field/field.tsx @@ -19,6 +19,7 @@ export type FieldContainerPortalProps = { field: Field; className?: string; children: React.ReactNode; + cardClassName?: string; }; export function FieldContainerPortal({ @@ -44,7 +45,7 @@ export function FieldContainerPortal({ ); } -export function FieldRootContainer({ field, children }: FieldContainerPortalProps) { +export function FieldRootContainer({ field, children, cardClassName }: FieldContainerPortalProps) { const [isValidating, setIsValidating] = useState(false); const ref = React.useRef(null); @@ -78,6 +79,7 @@ export function FieldRootContainer({ field, children }: FieldContainerPortalProp { 'border-orange-300 ring-1 ring-orange-300': !field.inserted && isValidating, }, + cardClassName, )} ref={ref} data-inserted={field.inserted ? 'true' : 'false'} diff --git a/packages/ui/primitives/document-flow/add-settings.tsx b/packages/ui/primitives/document-flow/add-settings.tsx index ea962dee5..ce52e03c2 100644 --- a/packages/ui/primitives/document-flow/add-settings.tsx +++ b/packages/ui/primitives/document-flow/add-settings.tsx @@ -9,7 +9,11 @@ import { useForm } from 'react-hook-form'; import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats'; import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth'; import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones'; -import { DocumentAccessAuth, DocumentActionAuth } from '@documenso/lib/types/document-auth'; +import { + DocumentAccessAuth, + DocumentActionAuth, + DocumentAuth, +} from '@documenso/lib/types/document-auth'; import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth'; import { DocumentStatus, type Field, type Recipient, SendStatus } from '@documenso/prisma/client'; import type { DocumentWithData } from '@documenso/prisma/types/document-with-data'; @@ -216,9 +220,9 @@ export const AddSettingsFormPartial = ({

    -
  • + {/*
  • Require account - The recipient must be signed in -
  • + */}
  • Require passkey - The recipient must have an account and passkey configured via their settings @@ -242,11 +246,13 @@ export const AddSettingsFormPartial = ({ - {Object.values(DocumentActionAuth).map((authType) => ( - - {DOCUMENT_AUTH_TYPES[authType].value} - - ))} + {Object.values(DocumentActionAuth) + .filter((auth) => auth !== DocumentAuth.ACCOUNT) + .map((authType) => ( + + {DOCUMENT_AUTH_TYPES[authType].value} + + ))} {/* Note: -1 is remapped in the Zod schema to the required value. */} None diff --git a/packages/ui/primitives/document-flow/add-signers.tsx b/packages/ui/primitives/document-flow/add-signers.tsx index b796f4328..2f9f2f234 100644 --- a/packages/ui/primitives/document-flow/add-signers.tsx +++ b/packages/ui/primitives/document-flow/add-signers.tsx @@ -302,10 +302,10 @@ export const AddSignersFormPartial = ({ global action signing authentication method configured in the "General Settings" step
  • -
  • + {/*
  • Require account - The recipient must be signed in -
  • + */}
  • Require passkey - The recipient must have an account and passkey configured via their settings @@ -326,11 +326,13 @@ export const AddSignersFormPartial = ({ {/* Note: -1 is remapped in the Zod schema to the required value. */} Inherit authentication method - {Object.values(RecipientActionAuth).map((authType) => ( - - {DOCUMENT_AUTH_TYPES[authType].value} - - ))} + {Object.values(RecipientActionAuth) + .filter((auth) => auth !== RecipientActionAuth.ACCOUNT) + .map((authType) => ( + + {DOCUMENT_AUTH_TYPES[authType].value} + + ))} diff --git a/packages/ui/primitives/popover.tsx b/packages/ui/primitives/popover.tsx index e84f6cc6d..62462322b 100644 --- a/packages/ui/primitives/popover.tsx +++ b/packages/ui/primitives/popover.tsx @@ -30,4 +30,66 @@ const PopoverContent = React.forwardRef< PopoverContent.displayName = PopoverPrimitive.Content.displayName; -export { Popover, PopoverTrigger, PopoverContent }; +type PopoverHoverProps = { + trigger: React.ReactNode; + children: React.ReactNode; + contentProps?: React.ComponentPropsWithoutRef; +}; + +const PopoverHover = ({ trigger, children, contentProps }: PopoverHoverProps) => { + const [open, setOpen] = React.useState(false); + + const isControlled = React.useRef(false); + const isMouseOver = React.useRef(false); + + const onMouseEnter = () => { + isMouseOver.current = true; + + if (isControlled.current) { + return; + } + + setOpen(true); + }; + + const onMouseLeave = () => { + isMouseOver.current = false; + + if (isControlled.current) { + return; + } + + setTimeout(() => { + setOpen(isMouseOver.current); + }, 200); + }; + + const onOpenChange = (newOpen: boolean) => { + isControlled.current = newOpen; + + setOpen(newOpen); + }; + + return ( + + + {trigger} + + + + {children} + + + ); +}; + +export { Popover, PopoverTrigger, PopoverContent, PopoverHover };