From f9eeaf1db805a3b36769e9c7b8be219337347490 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 15 Feb 2024 18:20:10 +1100 Subject: [PATCH] feat: add document version history UI --- .../[id]/document-page-view-information.tsx | 2 +- .../document-page-view-recent-activity.tsx | 146 ++++++++ .../[id]/document-page-view-recipients.tsx | 115 +++++++ .../documents/[id]/document-page-view.tsx | 171 +++------- .../document-history-sheet-changes.tsx | 28 ++ .../document/document-history-sheet.tsx | 316 ++++++++++++++++++ .../src/components/formatter/locale-date.tsx | 21 +- packages/lib/constants/document-audit-logs.ts | 19 ++ packages/lib/constants/recipient-roles.ts | 10 +- .../document-meta/upsert-document-meta.ts | 26 +- .../server-only/document/delete-document.ts | 89 ++++- .../document/find-document-audit-logs.ts | 115 +++++++ .../server-only/document/send-document.tsx | 28 +- packages/lib/types/document-audit-logs.ts | 65 +++- packages/lib/utils/document-audit-logs.ts | 124 ++++++- .../trpc/server/document-router/router.ts | 33 +- .../trpc/server/document-router/schema.ts | 13 + packages/trpc/server/team-router/schema.ts | 5 +- packages/ui/primitives/sheet.tsx | 9 +- packages/ui/styles/theme.css | 2 + 20 files changed, 1144 insertions(+), 193 deletions(-) create mode 100644 apps/web/src/app/(dashboard)/documents/[id]/document-page-view-recent-activity.tsx create mode 100644 apps/web/src/app/(dashboard)/documents/[id]/document-page-view-recipients.tsx create mode 100644 apps/web/src/components/document/document-history-sheet-changes.tsx create mode 100644 apps/web/src/components/document/document-history-sheet.tsx create mode 100644 packages/lib/constants/document-audit-logs.ts create mode 100644 packages/lib/server-only/document/find-document-audit-logs.ts diff --git a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-information.tsx b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-information.tsx index 00bbe0d83..af0bc8644 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-information.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-information.tsx @@ -52,7 +52,7 @@ export const DocumentPageViewInformation = ({ }, [isMounted, document, locale, userId]); return ( -
+

Information

    diff --git a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-recent-activity.tsx b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-recent-activity.tsx new file mode 100644 index 000000000..ef7d2e498 --- /dev/null +++ b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-recent-activity.tsx @@ -0,0 +1,146 @@ +'use client'; + +import { useMemo } from 'react'; + +import { CheckCheckIcon, CheckIcon, Loader, MailOpen } from 'lucide-react'; +import { DateTime } from 'luxon'; +import { match } from 'ts-pattern'; + +import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; +import { formatDocumentAuditLogAction } from '@documenso/lib/utils/document-audit-logs'; +import { trpc } from '@documenso/trpc/react'; +import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out'; +import { cn } from '@documenso/ui/lib/utils'; + +export type DocumentPageViewRecentActivityProps = { + documentId: number; + userId: number; +}; + +export const DocumentPageViewRecentActivity = ({ + documentId, + userId, +}: DocumentPageViewRecentActivityProps) => { + const { + data, + isLoading, + isLoadingError, + refetch, + hasNextPage, + fetchNextPage, + isFetchingNextPage, + } = trpc.document.findDocumentAuditLogs.useInfiniteQuery( + { + documentId, + filterForRecentActivity: true, + orderBy: { + column: 'createdAt', + direction: 'asc', + }, + }, + { + getNextPageParam: (lastPage) => lastPage.nextCursor, + }, + ); + + const documentAuditLogs = useMemo(() => (data?.pages ?? []).flatMap((page) => page.data), [data]); + + return ( +
    +
    +

    Recent activity

    + + {/* Can add dropdown menu here for additional options. */} +
    + + {isLoading && ( +
    + +
    + )} + + {isLoadingError && ( +
    +

    Unable to load document history

    + +
    + )} + + + {data && ( +
      + {hasNextPage && ( +
    • +
      +
      +
      + +
      +
      +
      + + +
    • + )} + + {documentAuditLogs.map((auditLog, auditLogIndex) => ( +
    • +
      +
      +
      + +
      + {match(auditLog.type) + .with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED, () => ( +
      +
      + )) + .with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED, () => ( +
      +
      + )) + .with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED, () => ( +
      +
      + )) + .otherwise(() => ( +
      + ))} +
      + +

      + + {formatDocumentAuditLogAction(auditLog, userId).prefix} + {' '} + {formatDocumentAuditLogAction(auditLog, userId).description} +

      + + +
    • + ))} +
    + )} +
    +
    + ); +}; diff --git a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-recipients.tsx b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-recipients.tsx new file mode 100644 index 000000000..37d2cd35e --- /dev/null +++ b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-recipients.tsx @@ -0,0 +1,115 @@ +import Link from 'next/link'; + +import { CheckIcon, Clock, MailIcon, MailOpenIcon, PenIcon, PlusIcon } from 'lucide-react'; +import { match } from 'ts-pattern'; + +import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; +import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client'; +import type { Document, Recipient } from '@documenso/prisma/client'; +import { SignatureIcon } from '@documenso/ui/icons/signature'; +import { AvatarWithText } from '@documenso/ui/primitives/avatar'; +import { Badge } from '@documenso/ui/primitives/badge'; + +export type DocumentPageViewRecipientsProps = { + document: Document & { + Recipient: Recipient[]; + }; + documentRootPath: string; +}; + +export const DocumentPageViewRecipients = ({ + document, + documentRootPath, +}: DocumentPageViewRecipientsProps) => { + const recipients = document.Recipient; + + return ( +
    +
    +

    Recipients

    + + {document.status !== DocumentStatus.COMPLETED && ( + + {recipients.length === 0 ? ( + + ) : ( + + )} + + )} +
    + +
      + {recipients.length === 0 && ( +
    • No recipients
    • + )} + + {recipients.map((recipient) => ( +
    • + {recipient.email}

      } + secondaryText={ +

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

      + } + /> + + {document.status !== DocumentStatus.DRAFT && + recipient.signingStatus === SigningStatus.SIGNED && ( + + {match(recipient.role) + .with(RecipientRole.APPROVER, () => ( + <> + + Approved + + )) + .with(RecipientRole.CC, () => + document.status === DocumentStatus.COMPLETED ? ( + <> + + Sent + + ) : ( + <> + + Ready + + ), + ) + + .with(RecipientRole.SIGNER, () => ( + <> + + Signed + + )) + .with(RecipientRole.VIEWER, () => ( + <> + + Viewed + + )) + .exhaustive()} + + )} + + {document.status !== DocumentStatus.DRAFT && + recipient.signingStatus === SigningStatus.NOT_SIGNED && ( + + + Pending + + )} +
    • + ))} +
    +
    + ); +}; 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 c821bfac8..c64b8650a 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 @@ -1,34 +1,23 @@ import Link from 'next/link'; import { redirect } from 'next/navigation'; -import { - CheckIcon, - ChevronLeft, - Clock, - MailIcon, - MailOpenIcon, - PenIcon, - PlusIcon, - Users2, -} from 'lucide-react'; +import { ChevronLeft, Clock9, Users2 } from 'lucide-react'; import { match } from 'ts-pattern'; import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto'; -import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id'; 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'; -import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client'; +import { DocumentStatus } from '@documenso/prisma/client'; import type { Team } from '@documenso/prisma/client'; -import { SignatureIcon } from '@documenso/ui/icons/signature'; -import { AvatarWithText } from '@documenso/ui/primitives/avatar'; -import { Badge } from '@documenso/ui/primitives/badge'; +import { Button } from '@documenso/ui/primitives/button'; import { Card, CardContent } from '@documenso/ui/primitives/card'; 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 { DocumentStatus as DocumentStatusComponent, FRIENDLY_STATUS_MAP, @@ -37,6 +26,8 @@ import { import { DocumentPageViewButton } from './document-page-view-button'; import { DocumentPageViewDropdown } from './document-page-view-dropdown'; import { DocumentPageViewInformation } from './document-page-view-information'; +import { DocumentPageViewRecentActivity } from './document-page-view-recent-activity'; +import { DocumentPageViewRecipients } from './document-page-view-recipients'; export type DocumentPageViewProps = { params: { @@ -104,27 +95,38 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps) Documents -
    -

    - {document.title} -

    +
    +
    +

    + {document.title} +

    -
    - +
    + - {recipients.length > 0 && ( -
    - + {recipients.length > 0 && ( +
    + - - {recipients.length} Recipient(s) - -
    - )} + + {recipients.length} Recipient(s) + +
    + )} +
    +
    + +
    + + +
    @@ -139,8 +141,8 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
    -
    -
    +
    +

    Document {FRIENDLY_STATUS_MAP[document.status].label.toLowerCase()} @@ -180,100 +182,13 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps) {/* Recipients section. */} -
    -
    -

    Recipients

    + - {document.status !== DocumentStatus.COMPLETED && ( - - {recipients.length === 0 ? ( - - ) : ( - - )} - - )} -
    - -
      - {recipients.length === 0 && ( -
    • - No recipients -
    • - )} - - {recipients.map((recipient) => ( -
    • - {recipient.email}

      - } - secondaryText={ -

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

      - } - /> - - {document.status !== DocumentStatus.DRAFT && - recipient.signingStatus === SigningStatus.SIGNED && ( - - {match(recipient.role) - .with(RecipientRole.APPROVER, () => ( - <> - - Approved - - )) - .with(RecipientRole.CC, () => - document.status === DocumentStatus.COMPLETED ? ( - <> - - Sent - - ) : ( - <> - - Ready - - ), - ) - - .with(RecipientRole.SIGNER, () => ( - <> - - Signed - - )) - .with(RecipientRole.VIEWER, () => ( - <> - - Viewed - - )) - .exhaustive()} - - )} - - {document.status !== DocumentStatus.DRAFT && - recipient.signingStatus === SigningStatus.NOT_SIGNED && ( - - - Pending - - )} -
    • - ))} -
    -
    + {/* Recent activity section. */} +

    diff --git a/apps/web/src/components/document/document-history-sheet-changes.tsx b/apps/web/src/components/document/document-history-sheet-changes.tsx new file mode 100644 index 000000000..ef3985a61 --- /dev/null +++ b/apps/web/src/components/document/document-history-sheet-changes.tsx @@ -0,0 +1,28 @@ +'use client'; + +import React from 'react'; + +import { Badge } from '@documenso/ui/primitives/badge'; + +export type DocumentHistorySheetChangesProps = { + values: { + key: string | React.ReactNode; + value: string | React.ReactNode; + }[]; +}; + +export const DocumentHistorySheetChanges = ({ values }: DocumentHistorySheetChangesProps) => { + return ( + + {values.map(({ key, value }, i) => ( +

    + {key}: + {value} +

    + ))} +
    + ); +}; diff --git a/apps/web/src/components/document/document-history-sheet.tsx b/apps/web/src/components/document/document-history-sheet.tsx new file mode 100644 index 000000000..29d9a9c96 --- /dev/null +++ b/apps/web/src/components/document/document-history-sheet.tsx @@ -0,0 +1,316 @@ +'use client'; + +import { useMemo, useState } from 'react'; + +import { ArrowRightIcon, Loader } from 'lucide-react'; +import { match } from 'ts-pattern'; +import { UAParser } from 'ua-parser-js'; + +import { DOCUMENT_AUDIT_LOG_EMAIL_FORMAT } from '@documenso/lib/constants/document-audit-logs'; +import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; +import { formatDocumentAuditLogActionString } from '@documenso/lib/utils/document-audit-logs'; +import { trpc } from '@documenso/trpc/react'; +import { cn } from '@documenso/ui/lib/utils'; +import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar'; +import { Badge } from '@documenso/ui/primitives/badge'; +import { Button } from '@documenso/ui/primitives/button'; +import { Sheet, SheetContent, SheetTrigger } from '@documenso/ui/primitives/sheet'; + +import { LocaleDate } from '~/components/formatter/locale-date'; + +import { DocumentHistorySheetChanges } from './document-history-sheet-changes'; + +export type DocumentHistorySheetProps = { + documentId: number; + userId: number; + isMenuOpen?: boolean; + onMenuOpenChange?: (_value: boolean) => void; + children?: React.ReactNode; +}; + +export const DocumentHistorySheet = ({ + documentId, + userId, + isMenuOpen, + onMenuOpenChange, + children, +}: DocumentHistorySheetProps) => { + const [isUserDetailsVisible, setIsUserDetailsVisible] = useState(false); + + const { + data, + isLoading, + isLoadingError, + refetch, + hasNextPage, + fetchNextPage, + isFetchingNextPage, + } = trpc.document.findDocumentAuditLogs.useInfiniteQuery( + { + documentId, + }, + { + getNextPageParam: (lastPage) => lastPage.nextCursor, + }, + ); + + const documentAuditLogs = useMemo(() => (data?.pages ?? []).flatMap((page) => page.data), [data]); + + const extractBrowser = (userAgent?: string | null) => { + if (!userAgent) { + return 'Unknown'; + } + + const parser = new UAParser(userAgent); + + parser.setUA(userAgent); + + const result = parser.getResult(); + + return result.browser.name; + }; + + /** + * Applies the following formatting for a given text: + * - Uppercase first lower, lowercase rest + * - Replace _ with spaces + * + * @param text The text to format + * @returns The formatted text + */ + const formatGenericText = (text: string) => { + return (text.charAt(0).toUpperCase() + text.slice(1).toLowerCase()).replaceAll('_', ' '); + }; + + return ( + + {children && {children}} + + +
    +

    Document history

    + +
    + + {isLoading && ( +
    + +
    + )} + + {isLoadingError && ( +
    +

    Unable to load document history

    + +
    + )} + + {data && ( +
      + {documentAuditLogs.map((auditLog) => ( +
    • +
      + + + {(auditLog?.email ?? auditLog?.name ?? '?').slice(0, 1).toUpperCase()} + + + +
      +

      + {formatDocumentAuditLogActionString(auditLog, userId)} +

      +

      + +

      +
      +
      + + {match(auditLog) + .with( + { type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED }, + { type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED }, + { type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED }, + { type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED }, + { type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED }, + { type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT }, + () => null, + ) + .with( + { type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED }, + { type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_DELETED }, + ({ data }) => { + const values = [ + { + key: 'Email', + value: data.recipientEmail, + }, + { + key: 'Role', + value: formatGenericText(data.recipientRole), + }, + ]; + + if (data.recipientName) { + values.unshift({ + key: 'Name', + value: data.recipientName, + }); + } + + return ; + }, + ) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED }, ({ data }) => { + if (data.changes.length === 0) { + return null; + } + + return ( + ({ + key: formatGenericText(type), + value: ( + + {type === 'ROLE' ? formatGenericText(from) : from} + + {type === 'ROLE' ? formatGenericText(to) : to} + + ), + }))} + /> + ); + }) + .with( + { type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_CREATED }, + { type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_DELETED }, + { type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_UPDATED }, + ({ data }) => ( + + ), + ) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED }, ({ data }) => { + if (data.changes.length === 0) { + return null; + } + + return ( + ({ + key: formatGenericText(change.type), + value: change.type === 'PASSWORD' ? '*********' : change.to, + }))} + /> + ); + }) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_TITLE_UPDATED }, ({ data }) => ( + + )) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED }, ({ data }) => ( + + )) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_UNINSERTED }, ({ data }) => ( + + )) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT }, ({ data }) => ( + + )) + .exhaustive()} + + {isUserDetailsVisible && ( + <> +
      + + IP: {auditLog.ipAddress ?? 'Unknown'} + + + + Browser: {extractBrowser(auditLog.userAgent)} + +
      + + )} +
    • + ))} + + {hasNextPage && ( +
      + +
      + )} +
    + )} +
    +
    + ); +}; diff --git a/apps/web/src/components/formatter/locale-date.tsx b/apps/web/src/components/formatter/locale-date.tsx index 7262a9a57..98a115f60 100644 --- a/apps/web/src/components/formatter/locale-date.tsx +++ b/apps/web/src/components/formatter/locale-date.tsx @@ -1,7 +1,7 @@ 'use client'; import type { HTMLAttributes } from 'react'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import type { DateTimeFormatOptions } from 'luxon'; import { DateTime } from 'luxon'; @@ -10,7 +10,7 @@ import { useLocale } from '@documenso/lib/client-only/providers/locale'; export type LocaleDateProps = HTMLAttributes & { date: string | number | Date; - format?: DateTimeFormatOptions; + format?: DateTimeFormatOptions | string; }; /** @@ -22,13 +22,24 @@ export type LocaleDateProps = HTMLAttributes & { export const LocaleDate = ({ className, date, format, ...props }: LocaleDateProps) => { const { locale } = useLocale(); + const formatDateTime = useCallback( + (date: DateTime) => { + if (typeof format === 'string') { + return date.toFormat(format); + } + + return date.toLocaleString(format); + }, + [format], + ); + const [localeDate, setLocaleDate] = useState(() => - DateTime.fromJSDate(new Date(date)).setLocale(locale).toLocaleString(format), + formatDateTime(DateTime.fromJSDate(new Date(date)).setLocale(locale)), ); useEffect(() => { - setLocaleDate(DateTime.fromJSDate(new Date(date)).toLocaleString(format)); - }, [date, format]); + setLocaleDate(formatDateTime(DateTime.fromJSDate(new Date(date)))); + }, [date, format, formatDateTime]); return ( diff --git a/packages/lib/constants/document-audit-logs.ts b/packages/lib/constants/document-audit-logs.ts new file mode 100644 index 000000000..8ae654977 --- /dev/null +++ b/packages/lib/constants/document-audit-logs.ts @@ -0,0 +1,19 @@ +import { DOCUMENT_EMAIL_TYPE } from '../types/document-audit-logs'; + +export const DOCUMENT_AUDIT_LOG_EMAIL_FORMAT = { + [DOCUMENT_EMAIL_TYPE.SIGNING_REQUEST]: { + description: 'Signing request', + }, + [DOCUMENT_EMAIL_TYPE.VIEW_REQUEST]: { + description: 'Viewing request', + }, + [DOCUMENT_EMAIL_TYPE.APPROVE_REQUEST]: { + description: 'Approval request', + }, + [DOCUMENT_EMAIL_TYPE.CC]: { + description: 'CC', + }, + [DOCUMENT_EMAIL_TYPE.DOCUMENT_COMPLETED]: { + description: 'Document completed', + }, +} satisfies Record; diff --git a/packages/lib/constants/recipient-roles.ts b/packages/lib/constants/recipient-roles.ts index d86026782..44e4c34da 100644 --- a/packages/lib/constants/recipient-roles.ts +++ b/packages/lib/constants/recipient-roles.ts @@ -1,29 +1,31 @@ import { RecipientRole } from '@documenso/prisma/client'; -export const RECIPIENT_ROLES_DESCRIPTION: { - [key in RecipientRole]: { actionVerb: string; progressiveVerb: string; roleName: string }; -} = { +export const RECIPIENT_ROLES_DESCRIPTION = { [RecipientRole.APPROVER]: { actionVerb: 'Approve', + actioned: 'Approved', progressiveVerb: 'Approving', roleName: 'Approver', }, [RecipientRole.CC]: { actionVerb: 'CC', + actioned: 'CCed', progressiveVerb: 'CC', roleName: 'Cc', }, [RecipientRole.SIGNER]: { actionVerb: 'Sign', + actioned: 'Signed', progressiveVerb: 'Signing', roleName: 'Signer', }, [RecipientRole.VIEWER]: { actionVerb: 'View', + actioned: 'Viewed', progressiveVerb: 'Viewing', roleName: 'Viewer', }, -}; +} satisfies Record; export const RECIPIENT_ROLE_TO_EMAIL_TYPE = { [RecipientRole.SIGNER]: 'SIGNING_REQUEST', diff --git a/packages/lib/server-only/document-meta/upsert-document-meta.ts b/packages/lib/server-only/document-meta/upsert-document-meta.ts index 5a1c1594e..d4781f280 100644 --- a/packages/lib/server-only/document-meta/upsert-document-meta.ts +++ b/packages/lib/server-only/document-meta/upsert-document-meta.ts @@ -89,17 +89,21 @@ export const upsertDocumentMeta = async ({ }, }); - await tx.documentAuditLog.create({ - data: createDocumentAuditLogData({ - type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED, - documentId, - user, - requestMetadata, - data: { - changes: diffDocumentMetaChanges(originalDocumentMeta ?? {}, upsertedDocumentMeta), - }, - }), - }); + const changes = diffDocumentMetaChanges(originalDocumentMeta ?? {}, upsertedDocumentMeta); + + if (changes.length > 0) { + await tx.documentAuditLog.create({ + data: createDocumentAuditLogData({ + type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED, + documentId, + user, + requestMetadata, + data: { + changes: diffDocumentMetaChanges(originalDocumentMeta ?? {}, upsertedDocumentMeta), + }, + }), + }); + } return upsertedDocumentMeta; }); diff --git a/packages/lib/server-only/document/delete-document.ts b/packages/lib/server-only/document/delete-document.ts index 22365a727..473177b9b 100644 --- a/packages/lib/server-only/document/delete-document.ts +++ b/packages/lib/server-only/document/delete-document.ts @@ -9,27 +9,72 @@ import { prisma } from '@documenso/prisma'; import { DocumentStatus } from '@documenso/prisma/client'; import { FROM_ADDRESS, FROM_NAME } from '../../constants/email'; +import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs'; +import type { RequestMetadata } from '../../universal/extract-request-metadata'; +import { createDocumentAuditLogData } from '../../utils/document-audit-logs'; export type DeleteDocumentOptions = { id: number; userId: number; status: DocumentStatus; + requestMetadata?: RequestMetadata; }; -export const deleteDocument = async ({ id, userId, status }: DeleteDocumentOptions) => { +export const deleteDocument = async ({ + id, + userId, + status, + requestMetadata, +}: DeleteDocumentOptions) => { + await prisma.document.findFirstOrThrow({ + where: { + id, + OR: [ + { + userId, + }, + { + team: { + members: { + some: { + userId, + }, + }, + }, + }, + ], + }, + }); + + const user = await prisma.user.findFirstOrThrow({ + where: { + id: userId, + }, + }); + // if the document is a draft, hard-delete if (status === DocumentStatus.DRAFT) { - return await prisma.document.delete({ where: { id, userId, status: DocumentStatus.DRAFT } }); + return await prisma.$transaction(async (tx) => { + // Currently redundant since deleting a document will delete the audit logs. + // However may be useful if we disassociate audit lgos and documents if required. + await tx.documentAuditLog.create({ + data: createDocumentAuditLogData({ + documentId: id, + type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED, + user, + requestMetadata, + data: { + type: 'HARD', + }, + }), + }); + + return await tx.document.delete({ where: { id, status: DocumentStatus.DRAFT } }); + }); } // if the document is pending, send cancellation emails to all recipients if (status === DocumentStatus.PENDING) { - const user = await prisma.user.findFirstOrThrow({ - where: { - id: userId, - }, - }); - const document = await prisma.document.findUnique({ where: { id, @@ -77,12 +122,26 @@ export const deleteDocument = async ({ id, userId, status }: DeleteDocumentOptio } // If the document is not a draft, only soft-delete. - return await prisma.document.update({ - where: { - id, - }, - data: { - deletedAt: new Date().toISOString(), - }, + return await prisma.$transaction(async (tx) => { + await tx.documentAuditLog.create({ + data: createDocumentAuditLogData({ + documentId: id, + type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED, + user, + requestMetadata, + data: { + type: 'SOFT', + }, + }), + }); + + return await tx.document.update({ + where: { + id, + }, + data: { + deletedAt: new Date().toISOString(), + }, + }); }); }; diff --git a/packages/lib/server-only/document/find-document-audit-logs.ts b/packages/lib/server-only/document/find-document-audit-logs.ts new file mode 100644 index 000000000..4f423ce8c --- /dev/null +++ b/packages/lib/server-only/document/find-document-audit-logs.ts @@ -0,0 +1,115 @@ +import type { FindResultSet } from '@documenso/lib/types/find-result-set'; +import { prisma } from '@documenso/prisma'; +import type { DocumentAuditLog } from '@documenso/prisma/client'; +import type { Prisma } from '@documenso/prisma/client'; + +import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs'; +import { parseDocumentAuditLogData } from '../../utils/document-audit-logs'; + +export interface FindDocumentAuditLogsOptions { + userId: number; + documentId: number; + page?: number; + perPage?: number; + orderBy?: { + column: keyof DocumentAuditLog; + direction: 'asc' | 'desc'; + }; + cursor?: string; + filterForRecentActivity?: boolean; +} + +export const findDocumentAuditLogs = async ({ + userId, + documentId, + page = 1, + perPage = 30, + orderBy, + cursor, + filterForRecentActivity, +}: FindDocumentAuditLogsOptions) => { + const orderByColumn = orderBy?.column ?? 'createdAt'; + const orderByDirection = orderBy?.direction ?? 'desc'; + + await prisma.document.findFirstOrThrow({ + where: { + id: documentId, + OR: [ + { + userId, + }, + { + team: { + members: { + some: { + userId, + }, + }, + }, + }, + ], + }, + }); + + const whereClause: Prisma.DocumentAuditLogWhereInput = { + documentId, + }; + + // Filter events down to what we consider recent activity. + if (filterForRecentActivity) { + whereClause.OR = [ + { + type: { + in: [ + DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED, + DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED, + DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED, + DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED, + DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED, + DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT, + ], + }, + }, + { + type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT, + data: { + path: ['isResending'], + equals: true, + }, + }, + ]; + } + + const [data, count] = await Promise.all([ + prisma.documentAuditLog.findMany({ + where: whereClause, + skip: Math.max(page - 1, 0) * perPage, + take: perPage + 1, + orderBy: { + [orderByColumn]: orderByDirection, + }, + cursor: cursor ? { id: cursor } : undefined, + }), + prisma.documentAuditLog.count({ + where: whereClause, + }), + ]); + + let nextCursor: string | undefined = undefined; + + const parsedData = data.map((auditLog) => parseDocumentAuditLogData(auditLog)); + + if (parsedData.length > perPage) { + const nextItem = parsedData.pop(); + nextCursor = nextItem!.id; + } + + return { + data: parsedData, + count, + currentPage: Math.max(page, 1), + perPage, + totalPages: Math.ceil(count / perPage), + nextCursor, + } satisfies FindResultSet & { nextCursor?: string }; +}; diff --git a/packages/lib/server-only/document/send-document.tsx b/packages/lib/server-only/document/send-document.tsx index fc174c084..aa44ccedf 100644 --- a/packages/lib/server-only/document/send-document.tsx +++ b/packages/lib/server-only/document/send-document.tsx @@ -152,13 +152,27 @@ export const sendDocument = async ({ }), ); - const updatedDocument = await prisma.document.update({ - where: { - id: documentId, - }, - data: { - status: DocumentStatus.PENDING, - }, + const updatedDocument = await prisma.$transaction(async (tx) => { + if (document.status === DocumentStatus.DRAFT) { + await tx.documentAuditLog.create({ + data: createDocumentAuditLogData({ + type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT, + documentId: document.id, + requestMetadata, + user, + data: {}, + }), + }); + } + + return await tx.document.update({ + where: { + id: documentId, + }, + data: { + status: DocumentStatus.PENDING, + }, + }); }); return updatedDocument; diff --git a/packages/lib/types/document-audit-logs.ts b/packages/lib/types/document-audit-logs.ts index e6a954603..14d594786 100644 --- a/packages/lib/types/document-audit-logs.ts +++ b/packages/lib/types/document-audit-logs.ts @@ -21,15 +21,24 @@ export const ZDocumentAuditLogTypeSchema = z.enum([ 'RECIPIENT_UPDATED', // Document events. + 'DOCUMENT_COMPLETED', // When the document is sealed and fully completed. + 'DOCUMENT_CREATED', // When the document is created. + 'DOCUMENT_DELETED', // When the document is soft deleted. + 'DOCUMENT_FIELD_INSERTED', // When a field is inserted (signed/approved/etc) by a recipient. + 'DOCUMENT_FIELD_UNINSERTED', // When a field is uninserted by a recipient. + 'DOCUMENT_META_UPDATED', // When the document meta data is updated. + 'DOCUMENT_OPENED', // When the document is opened by a recipient. + 'DOCUMENT_RECIPIENT_COMPLETED', // When a recipient completes all their required tasks for the document. + 'DOCUMENT_SENT', // When the document transitions from DRAFT to PENDING. + 'DOCUMENT_TITLE_UPDATED', // When the document title is updated. +]); + +export const ZDocumentAuditLogEmailTypeSchema = z.enum([ + 'SIGNING_REQUEST', + 'VIEW_REQUEST', + 'APPROVE_REQUEST', + 'CC', 'DOCUMENT_COMPLETED', - 'DOCUMENT_CREATED', - 'DOCUMENT_DELETED', - 'DOCUMENT_FIELD_INSERTED', - 'DOCUMENT_FIELD_UNINSERTED', - 'DOCUMENT_META_UPDATED', - 'DOCUMENT_OPENED', - 'DOCUMENT_TITLE_UPDATED', - 'DOCUMENT_RECIPIENT_COMPLETED', ]); export const ZDocumentMetaDiffTypeSchema = z.enum([ @@ -40,10 +49,12 @@ export const ZDocumentMetaDiffTypeSchema = z.enum([ 'SUBJECT', 'TIMEZONE', ]); + export const ZFieldDiffTypeSchema = z.enum(['DIMENSION', 'POSITION']); export const ZRecipientDiffTypeSchema = z.enum(['NAME', 'ROLE', 'EMAIL']); export const DOCUMENT_AUDIT_LOG_TYPE = ZDocumentAuditLogTypeSchema.Enum; +export const DOCUMENT_EMAIL_TYPE = ZDocumentAuditLogEmailTypeSchema.Enum; export const DOCUMENT_META_DIFF_TYPE = ZDocumentMetaDiffTypeSchema.Enum; export const FIELD_DIFF_TYPE = ZFieldDiffTypeSchema.Enum; export const RECIPIENT_DIFF_TYPE = ZRecipientDiffTypeSchema.Enum; @@ -140,13 +151,7 @@ const ZBaseRecipientDataSchema = z.object({ export const ZDocumentAuditLogEventEmailSentSchema = z.object({ type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT), data: ZBaseRecipientDataSchema.extend({ - emailType: z.enum([ - 'SIGNING_REQUEST', - 'VIEW_REQUEST', - 'APPROVE_REQUEST', - 'CC', - 'DOCUMENT_COMPLETED', - ]), + emailType: ZDocumentAuditLogEmailTypeSchema, isResending: z.boolean(), }), }); @@ -171,6 +176,16 @@ export const ZDocumentAuditLogEventDocumentCreatedSchema = z.object({ }), }); +/** + * Event: Document deleted. + */ +export const ZDocumentAuditLogEventDocumentDeletedSchema = z.object({ + type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED), + data: z.object({ + type: z.enum(['SOFT', 'HARD']), + }), +}); + /** * Event: Document field inserted. */ @@ -247,6 +262,14 @@ export const ZDocumentAuditLogEventDocumentRecipientCompleteSchema = z.object({ data: ZBaseRecipientDataSchema, }); +/** + * Event: Document sent. + */ +export const ZDocumentAuditLogEventDocumentSentSchema = z.object({ + type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT), + data: z.object({}), +}); + /** * Event: Document title updated. */ @@ -314,6 +337,11 @@ export const ZDocumentAuditLogBaseSchema = z.object({ id: z.string(), createdAt: z.date(), documentId: z.number(), + name: z.string().optional().nullable(), + email: z.string().optional().nullable(), + userId: z.number().optional().nullable(), + userAgent: z.string().optional().nullable(), + ipAddress: z.string().optional().nullable(), }); export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and( @@ -321,11 +349,13 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and( ZDocumentAuditLogEventEmailSentSchema, ZDocumentAuditLogEventDocumentCompletedSchema, ZDocumentAuditLogEventDocumentCreatedSchema, + ZDocumentAuditLogEventDocumentDeletedSchema, ZDocumentAuditLogEventDocumentFieldInsertedSchema, ZDocumentAuditLogEventDocumentFieldUninsertedSchema, ZDocumentAuditLogEventDocumentMetaUpdatedSchema, ZDocumentAuditLogEventDocumentOpenedSchema, ZDocumentAuditLogEventDocumentRecipientCompleteSchema, + ZDocumentAuditLogEventDocumentSentSchema, ZDocumentAuditLogEventDocumentTitleUpdatedSchema, ZDocumentAuditLogEventFieldCreatedSchema, ZDocumentAuditLogEventFieldRemovedSchema, @@ -348,3 +378,8 @@ export type TDocumentAuditLogDocumentMetaDiffSchema = z.infer< export type TDocumentAuditLogRecipientDiffSchema = z.infer< typeof ZDocumentAuditLogRecipientDiffSchema >; + +export type DocumentAuditLogByType = Extract< + TDocumentAuditLog, + { type: T } +>; diff --git a/packages/lib/utils/document-audit-logs.ts b/packages/lib/utils/document-audit-logs.ts index dcc3932e9..65ffb2817 100644 --- a/packages/lib/utils/document-audit-logs.ts +++ b/packages/lib/utils/document-audit-logs.ts @@ -1,5 +1,14 @@ -import type { DocumentAuditLog, DocumentMeta, Field, Recipient } from '@documenso/prisma/client'; +import { match } from 'ts-pattern'; +import type { + DocumentAuditLog, + DocumentMeta, + Field, + Recipient, + RecipientRole, +} from '@documenso/prisma/client'; + +import { RECIPIENT_ROLES_DESCRIPTION } from '../constants/recipient-roles'; import type { TDocumentAuditLog, TDocumentAuditLogDocumentMetaDiffSchema, @@ -7,6 +16,7 @@ import type { TDocumentAuditLogRecipientDiffSchema, } from '../types/document-audit-logs'; import { + DOCUMENT_AUDIT_LOG_TYPE, DOCUMENT_META_DIFF_TYPE, FIELD_DIFF_TYPE, RECIPIENT_DIFF_TYPE, @@ -58,6 +68,7 @@ export const parseDocumentAuditLogData = (auditLog: DocumentAuditLog): TDocument // Handle any required migrations here. if (!data.success) { + console.error(data.error); throw new Error('Migration required'); } @@ -203,3 +214,114 @@ export const diffDocumentMetaChanges = ( return diffs; }; + +/** + * Formats the audit log into a description of the action. + * + * Provide a userId to prefix the action with the user, example 'X did Y'. + */ +export const formatDocumentAuditLogActionString = ( + auditLog: TDocumentAuditLog, + userId?: number, +) => { + const { prefix, description } = formatDocumentAuditLogAction(auditLog, userId); + + return prefix ? `${prefix} ${description}` : description; +}; + +/** + * Formats the audit log into a description of the action. + * + * Provide a userId to prefix the action with the user, example 'X did Y'. + */ +export const formatDocumentAuditLogAction = (auditLog: TDocumentAuditLog, userId?: number) => { + let prefix = userId === auditLog.userId ? 'You' : auditLog.name || auditLog.email || ''; + + const description = match(auditLog) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_CREATED }, () => ({ + anonymous: 'A field was added', + identified: 'added a field', + })) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_DELETED }, () => ({ + anonymous: 'A field was removed', + identified: 'removed a field', + })) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_UPDATED }, () => ({ + anonymous: 'A field was updated', + identified: 'updated a field', + })) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED }, () => ({ + anonymous: 'A recipient was added', + identified: 'added a recipient', + })) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_DELETED }, () => ({ + anonymous: 'A recipient was removed', + identified: 'removed a recipient', + })) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED }, () => ({ + anonymous: 'A recipient was updated', + identified: 'updated a recipient', + })) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED }, () => ({ + anonymous: 'Document created', + identified: 'created the document', + })) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED }, () => ({ + anonymous: 'Document deleted', + identified: 'deleted the document', + })) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED }, () => ({ + anonymous: 'Field signed', + identified: 'signed a field', + })) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_UNINSERTED }, () => ({ + anonymous: 'Field unsigned', + identified: 'unsigned a field', + })) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED }, () => ({ + anonymous: 'Document updated', + identified: 'updated the document', + })) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED }, () => ({ + anonymous: 'Document opened', + identified: 'opened the document', + })) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_TITLE_UPDATED }, () => ({ + anonymous: 'Document title updated', + identified: 'updated the document title', + })) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT }, () => ({ + anonymous: 'Document sent', + identified: 'sent the document', + })) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED }, ({ data }) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const action = RECIPIENT_ROLES_DESCRIPTION[data.recipientRole as RecipientRole]?.actioned; + + const value = action ? `${action.toLowerCase()} the document` : 'completed their task'; + + return { + anonymous: `Recipient ${value}`, + identified: value, + }; + }) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT }, ({ data }) => ({ + anonymous: `Email ${data.isResending ? 'resent' : 'sent'}`, + identified: `${data.isResending ? 'resent' : 'sent'} an email`, + })) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED }, () => { + // Clear the prefix since this should be considered an 'anonymous' event. + prefix = ''; + + return { + anonymous: 'Document completed', + identified: 'Document completed', + }; + }) + .exhaustive(); + + return { + prefix, + description: prefix ? description.identified : description.anonymous, + }; +}; diff --git a/packages/trpc/server/document-router/router.ts b/packages/trpc/server/document-router/router.ts index aebc6e505..cd9491fd6 100644 --- a/packages/trpc/server/document-router/router.ts +++ b/packages/trpc/server/document-router/router.ts @@ -6,6 +6,7 @@ import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/ups import { createDocument } from '@documenso/lib/server-only/document/create-document'; import { deleteDocument } from '@documenso/lib/server-only/document/delete-document'; import { duplicateDocumentById } from '@documenso/lib/server-only/document/duplicate-document-by-id'; +import { findDocumentAuditLogs } from '@documenso/lib/server-only/document/find-document-audit-logs'; import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id'; import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token'; import { resendDocument } from '@documenso/lib/server-only/document/resend-document'; @@ -21,6 +22,7 @@ import { authenticatedProcedure, procedure, router } from '../trpc'; import { ZCreateDocumentMutationSchema, ZDeleteDraftDocumentMutationSchema, + ZFindDocumentAuditLogsQuerySchema, ZGetDocumentByIdQuerySchema, ZGetDocumentByTokenQuerySchema, ZResendDocumentMutationSchema, @@ -111,7 +113,12 @@ export const documentRouter = router({ const userId = ctx.user.id; - return await deleteDocument({ id, userId, status }); + return await deleteDocument({ + id, + userId, + status, + requestMetadata: extractNextApiRequestMetadata(ctx.req), + }); } catch (err) { console.error(err); @@ -122,6 +129,30 @@ export const documentRouter = router({ } }), + findDocumentAuditLogs: authenticatedProcedure + .input(ZFindDocumentAuditLogsQuerySchema) + .query(async ({ input, ctx }) => { + try { + const { perPage, documentId, cursor, filterForRecentActivity, orderBy } = input; + + return await findDocumentAuditLogs({ + perPage, + documentId, + cursor, + filterForRecentActivity, + orderBy, + userId: ctx.user.id, + }); + } catch (err) { + console.error(err); + + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'We were unable to find audit logs for this document. Please try again later.', + }); + } + }), + setTitleForDocument: authenticatedProcedure .input(ZSetTitleForDocumentMutationSchema) .mutation(async ({ input, ctx }) => { diff --git a/packages/trpc/server/document-router/schema.ts b/packages/trpc/server/document-router/schema.ts index 899baa41f..83c05b3b3 100644 --- a/packages/trpc/server/document-router/schema.ts +++ b/packages/trpc/server/document-router/schema.ts @@ -1,8 +1,21 @@ import { z } from 'zod'; import { URL_REGEX } from '@documenso/lib/constants/url-regex'; +import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params'; import { DocumentStatus, FieldType, RecipientRole } from '@documenso/prisma/client'; +export const ZFindDocumentAuditLogsQuerySchema = ZBaseTableSearchParamsSchema.extend({ + documentId: z.number().min(1), + cursor: z.string().optional(), + filterForRecentActivity: z.boolean().optional(), + orderBy: z + .object({ + column: z.enum(['createdAt', 'type']), + direction: z.enum(['asc', 'desc']), + }) + .optional(), +}); + export const ZGetDocumentByIdQuerySchema = z.object({ id: z.number().min(1), teamId: z.number().min(1).optional(), diff --git a/packages/trpc/server/team-router/schema.ts b/packages/trpc/server/team-router/schema.ts index 953b12490..75c307e35 100644 --- a/packages/trpc/server/team-router/schema.ts +++ b/packages/trpc/server/team-router/schema.ts @@ -3,10 +3,11 @@ import { z } from 'zod'; import { PROTECTED_TEAM_URLS } from '@documenso/lib/constants/teams'; import { TeamMemberRole } from '@documenso/prisma/client'; +// Consider refactoring to use ZBaseTableSearchParamsSchema. const GenericFindQuerySchema = z.object({ term: z.string().optional(), - page: z.number().optional(), - perPage: z.number().optional(), + page: z.number().min(1).optional(), + perPage: z.number().min(1).optional(), }); /** diff --git a/packages/ui/primitives/sheet.tsx b/packages/ui/primitives/sheet.tsx index a6326de0f..ef5348e59 100644 --- a/packages/ui/primitives/sheet.tsx +++ b/packages/ui/primitives/sheet.tsx @@ -143,14 +143,17 @@ const sheetVariants = cva( export interface DialogContentProps extends React.ComponentPropsWithoutRef, - VariantProps {} + VariantProps { + showOverlay?: boolean; + sheetClass?: string; +} const SheetContent = React.forwardRef< React.ElementRef, DialogContentProps ->(({ position, size, className, children, ...props }, ref) => ( +>(({ position, size, className, sheetClass, showOverlay = true, children, ...props }, ref) => ( - + {showOverlay && }