From 0cc729e9bd0f25b7854fe90b60bca16592a94e37 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Mon, 30 Jun 2025 19:11:16 +1000 Subject: [PATCH] feat: add sequential document view logs (#1871) ## Description Add a new document audit log to detect when the document is viewed. This should only be visible in the document audit log page Notes: 1. I wanted to reuse the `DOCUMENT_OPENED` event and add an additional paramter to track sequential views, but it's not query-able 2. This will log "DOCUMENT_VIEWED" before "DOCUMENT_OPENED" but i don't think it matters --- .../document-history-sheet-changes.tsx | 26 -- .../document/document-history-sheet.tsx | 410 ------------------ .../t.$teamUrl+/documents.$id._index.tsx | 18 +- .../server-only/document/viewed-document.ts | 25 +- packages/lib/types/document-audit-logs.ts | 18 + packages/lib/utils/document-audit-logs.ts | 4 + 6 files changed, 47 insertions(+), 454 deletions(-) delete mode 100644 apps/remix/app/components/general/document/document-history-sheet-changes.tsx delete mode 100644 apps/remix/app/components/general/document/document-history-sheet.tsx diff --git a/apps/remix/app/components/general/document/document-history-sheet-changes.tsx b/apps/remix/app/components/general/document/document-history-sheet-changes.tsx deleted file mode 100644 index 577dbc473..000000000 --- a/apps/remix/app/components/general/document/document-history-sheet-changes.tsx +++ /dev/null @@ -1,26 +0,0 @@ -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/remix/app/components/general/document/document-history-sheet.tsx b/apps/remix/app/components/general/document/document-history-sheet.tsx deleted file mode 100644 index f7c70bc84..000000000 --- a/apps/remix/app/components/general/document/document-history-sheet.tsx +++ /dev/null @@ -1,410 +0,0 @@ -import { useMemo, useState } from 'react'; - -import { useLingui } from '@lingui/react'; -import { Trans } from '@lingui/react/macro'; -import { ArrowRightIcon, Loader } from 'lucide-react'; -import { DateTime } from 'luxon'; -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_AUTH_TYPES } from '@documenso/lib/constants/document-auth'; -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 { 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 { 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 { _, i18n } = useLingui(); - - const [isUserDetailsVisible, setIsUserDetailsVisible] = useState(false); - - const { - data, - isLoading, - isLoadingError, - refetch, - hasNextPage, - fetchNextPage, - isFetchingNextPage, - } = trpc.document.findDocumentAuditLogs.useInfiniteQuery( - { - documentId, - }, - { - getNextPageParam: (lastPage) => lastPage.nextCursor, - placeholderData: (previousData) => previousData, - }, - ); - - 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 | string[] | null): string => { - if (!text) { - return ''; - } - - if (Array.isArray(text)) { - return text.map((t) => formatGenericText(t)).join(', '); - } - - 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()} - - - -
    -

    - {formatDocumentAuditLogAction(_, auditLog, userId).description} -

    -

    - {DateTime.fromJSDate(auditLog.createdAt) - .setLocale(i18n.locales?.[0] || i18n.locale) - .toFormat('d MMM, yyyy HH:MM a')} -

    -
    -
    - - {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_RECIPIENT_REJECTED }, - { type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT }, - { type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_MOVED_TO_TEAM }, - () => 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), - }, - ]; - - // Insert the name to the start of the array if available. - 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_GLOBAL_AUTH_ACCESS_UPDATED }, - { type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACTION_UPDATED }, - ({ data }) => ( - DOCUMENT_AUTH_TYPES[f]?.value || 'None') - .join(', ') - : DOCUMENT_AUTH_TYPES[data.from || '']?.value || 'None', - }, - { - key: 'New', - value: Array.isArray(data.to) - ? data.to - .map((f) => DOCUMENT_AUTH_TYPES[f]?.value || 'None') - .join(', ') - : DOCUMENT_AUTH_TYPES[data.to || '']?.value || 'None', - }, - ]} - /> - ), - ) - .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_EXTERNAL_ID_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 }) => ( - - )) - .with( - { type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_VISIBILITY_UPDATED }, - ({ data }) => ( - - ), - ) - .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_PREFILLED }, ({ data }) => ( - - )) - .exhaustive()} - - {isUserDetailsVisible && ( - <> -
    - - IP: {auditLog.ipAddress ?? 'Unknown'} - - - - Browser: {extractBrowser(auditLog.userAgent)} - -
    - - )} -
  • - ))} - - {hasNextPage && ( -
    - -
    - )} -
- )} -
-
- ); -}; diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._index.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._index.tsx index 6513bddc0..99c2bd243 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._index.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._index.tsx @@ -1,7 +1,7 @@ import { useLingui } from '@lingui/react'; import { Plural, Trans } from '@lingui/react/macro'; import { DocumentStatus, TeamMemberRole } from '@prisma/client'; -import { ChevronLeft, Clock9, Users2 } from 'lucide-react'; +import { ChevronLeft, Users2 } from 'lucide-react'; import { Link, redirect } from 'react-router'; import { match } from 'ts-pattern'; @@ -13,11 +13,9 @@ import { DocumentVisibility } from '@documenso/lib/types/document-visibility'; import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields'; import { Badge } from '@documenso/ui/primitives/badge'; -import { Button } from '@documenso/ui/primitives/button'; import { Card, CardContent } from '@documenso/ui/primitives/card'; import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer'; -import { DocumentHistorySheet } from '~/components/general/document/document-history-sheet'; import { DocumentPageViewButton } from '~/components/general/document/document-page-view-button'; import { DocumentPageViewDropdown } from '~/components/general/document/document-page-view-dropdown'; import { DocumentPageViewInformation } from '~/components/general/document/document-page-view-information'; @@ -101,9 +99,6 @@ export default function DocumentPage() { const { recipients, documentData, documentMeta } = document; - // This was a feature flag. Leave to false since it's not ready. - const isDocumentHistoryEnabled = false; - return (
{document.status === DocumentStatus.PENDING && ( @@ -154,17 +149,6 @@ export default function DocumentPage() { )}
- - {isDocumentHistoryEnabled && ( -
- - - -
- )}
diff --git a/packages/lib/server-only/document/viewed-document.ts b/packages/lib/server-only/document/viewed-document.ts index fbc0aaa2d..a9faf992a 100644 --- a/packages/lib/server-only/document/viewed-document.ts +++ b/packages/lib/server-only/document/viewed-document.ts @@ -27,7 +27,6 @@ export const viewedDocument = async ({ const recipient = await prisma.recipient.findFirst({ where: { token, - readStatus: ReadStatus.NOT_OPENED, }, }); @@ -37,6 +36,30 @@ export const viewedDocument = async ({ const { documentId } = recipient; + await prisma.documentAuditLog.create({ + data: createDocumentAuditLogData({ + type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_VIEWED, + documentId, + user: { + name: recipient.name, + email: recipient.email, + }, + requestMetadata, + data: { + recipientEmail: recipient.email, + recipientId: recipient.id, + recipientName: recipient.name, + recipientRole: recipient.role, + accessAuth: recipientAccessAuth ?? [], + }, + }), + }); + + // Early return if already opened. + if (recipient.readStatus === ReadStatus.OPENED) { + return; + } + await prisma.$transaction(async (tx) => { await tx.recipient.update({ where: { diff --git a/packages/lib/types/document-audit-logs.ts b/packages/lib/types/document-audit-logs.ts index 25679287a..5c3f5bd86 100644 --- a/packages/lib/types/document-audit-logs.ts +++ b/packages/lib/types/document-audit-logs.ts @@ -33,6 +33,7 @@ export const ZDocumentAuditLogTypeSchema = z.enum([ 'DOCUMENT_GLOBAL_AUTH_ACTION_UPDATED', // When the global action authentication is updated. 'DOCUMENT_META_UPDATED', // When the document meta data is updated. 'DOCUMENT_OPENED', // When the document is opened by a recipient. + 'DOCUMENT_VIEWED', // When the document is viewed by a recipient. 'DOCUMENT_RECIPIENT_REJECTED', // When a recipient rejects the document. 'DOCUMENT_RECIPIENT_COMPLETED', // When a recipient completes all their required tasks for the document. 'DOCUMENT_SENT', // When the document transitions from DRAFT to PENDING. @@ -438,6 +439,22 @@ export const ZDocumentAuditLogEventDocumentOpenedSchema = z.object({ }), }); +/** + * Event: Document viewed. + */ +export const ZDocumentAuditLogEventDocumentViewedSchema = z.object({ + type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_VIEWED), + data: ZBaseRecipientDataSchema.extend({ + accessAuth: z.preprocess((unknownValue) => { + if (!unknownValue) { + return []; + } + + return Array.isArray(unknownValue) ? unknownValue : [unknownValue]; + }, z.array(ZRecipientAccessAuthTypesSchema)), + }), +}); + /** * Event: Document recipient completed the document (the recipient has fully actioned and completed their required steps for the document). */ @@ -601,6 +618,7 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and( ZDocumentAuditLogEventDocumentGlobalAuthActionUpdatedSchema, ZDocumentAuditLogEventDocumentMetaUpdatedSchema, ZDocumentAuditLogEventDocumentOpenedSchema, + ZDocumentAuditLogEventDocumentViewedSchema, ZDocumentAuditLogEventDocumentRecipientCompleteSchema, ZDocumentAuditLogEventDocumentRecipientRejectedSchema, ZDocumentAuditLogEventDocumentSentSchema, diff --git a/packages/lib/utils/document-audit-logs.ts b/packages/lib/utils/document-audit-logs.ts index 287d4a823..58797510f 100644 --- a/packages/lib/utils/document-audit-logs.ts +++ b/packages/lib/utils/document-audit-logs.ts @@ -338,6 +338,10 @@ export const formatDocumentAuditLogAction = ( anonymous: msg`Document opened`, identified: msg`${prefix} opened the document`, })) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_VIEWED }, () => ({ + anonymous: msg`Document viewed`, + identified: msg`${prefix} viewed the document`, + })) .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_TITLE_UPDATED }, () => ({ anonymous: msg`Document title updated`, identified: msg`${prefix} updated the document title`,