From 8b95b9a7c0b5fb68f396e96f67d09a71b0c861b3 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Mon, 20 Jan 2025 03:24:30 +0000 Subject: [PATCH] feat: audit logs for copying links --- .../[id]/document-page-view-recipients.tsx | 31 ++++++++--- .../documents/[id]/document-page-view.tsx | 6 +-- .../[id]/edit/document-edit-page-view.tsx | 4 +- .../documents/data-table-action-dropdown.tsx | 3 +- .../app/(dashboard)/documents/data-table.tsx | 1 + .../template-page-view-documents-table.tsx | 1 + .../avatar/avatar-with-recipient.tsx | 24 +++++++-- .../avatar/stack-avatars-with-tooltip.tsx | 5 ++ .../document-recipient-link-copy-dialog.tsx | 51 ++++++++++++++++--- packages/lib/types/document-audit-logs.ts | 12 +++++ packages/lib/utils/document-audit-logs.ts | 4 ++ .../trpc/server/document-router/router.ts | 35 +++++++++++++ .../primitives/document-flow/add-subject.tsx | 31 ++++++++--- 13 files changed, 178 insertions(+), 30 deletions(-) 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 index ea8ccee15..177bf8bfd 100644 --- 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 @@ -17,8 +17,9 @@ import { match } from 'ts-pattern'; import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import { formatSigningLink } from '@documenso/lib/utils/recipients'; -import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client'; import type { Document, Recipient } from '@documenso/prisma/client'; +import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client'; +import { trpc } from '@documenso/trpc/react'; import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button'; import { SignatureIcon } from '@documenso/ui/icons/signature'; import { AvatarWithText } from '@documenso/ui/primitives/avatar'; @@ -40,8 +41,29 @@ export const DocumentPageViewRecipients = ({ const { _ } = useLingui(); const { toast } = useToast(); + const { mutateAsync: createAuditLog } = trpc.document.createAuditLog.useMutation(); + const recipients = document.recipients; + const onCopyLink = (recipient: Recipient) => { + toast({ + title: _(msg`Copied to clipboard`), + description: _(msg`The signing link has been copied to your clipboard.`), + }); + + void createAuditLog({ + documentId: document.id, + type: 'DOCUMENT_SIGNING_LINK_COPIED', + data: { + recipientEmail: recipient.email, + recipientName: recipient.name, + recipientId: recipient.id, + recipientRole: recipient.role, + isBulkCopy: false, + }, + }); + }; + return (
@@ -157,12 +179,7 @@ export const DocumentPageViewRecipients = ({ recipient.role !== RecipientRole.CC && ( { - toast({ - title: _(msg`Copied to clipboard`), - description: _(msg`The signing link has been copied to your clipboard.`), - }); - }} + onCopySuccess={() => void onCopyLink(recipient)} /> )}
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 7892e6eba..0f92f7168 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 @@ -15,9 +15,8 @@ import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/g import { DocumentVisibility } from '@documenso/lib/types/document-visibility'; import { symmetricDecrypt } from '@documenso/lib/universal/crypto'; import { formatDocumentsPath } from '@documenso/lib/utils/teams'; -import { DocumentStatus } from '@documenso/prisma/client'; import type { Team, TeamEmail } from '@documenso/prisma/client'; -import { TeamMemberRole } from '@documenso/prisma/client'; +import { DocumentStatus, TeamMemberRole } from '@documenso/prisma/client'; import { Badge } from '@documenso/ui/primitives/badge'; import { Button } from '@documenso/ui/primitives/button'; import { Card, CardContent } from '@documenso/ui/primitives/card'; @@ -137,7 +136,7 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps) return (
{document.status === DocumentStatus.PENDING && ( - + )} @@ -169,6 +168,7 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps) recipients={recipients} documentStatus={document.status} position="bottom" + documentId={document.id} > {recipients.length} Recipient(s) diff --git a/apps/web/src/app/(dashboard)/documents/[id]/edit/document-edit-page-view.tsx b/apps/web/src/app/(dashboard)/documents/[id]/edit/document-edit-page-view.tsx index 70e3323e2..14bb9f4ce 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/edit/document-edit-page-view.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/edit/document-edit-page-view.tsx @@ -13,8 +13,7 @@ import { DocumentVisibility } from '@documenso/lib/types/document-visibility'; import { symmetricDecrypt } from '@documenso/lib/universal/crypto'; import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import type { Team } from '@documenso/prisma/client'; -import { TeamMemberRole } from '@documenso/prisma/client'; -import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client'; +import { DocumentStatus as InternalDocumentStatus, TeamMemberRole } from '@documenso/prisma/client'; import { EditDocumentForm } from '~/app/(dashboard)/documents/[id]/edit-document'; import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip'; @@ -127,6 +126,7 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie recipients={recipients} documentStatus={document.status} position="bottom" + documentId={document.id} > diff --git a/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx b/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx index 7e46fb4bc..43ca94ff3 100644 --- a/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx +++ b/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx @@ -23,8 +23,8 @@ import { useSession } from 'next-auth/react'; import { downloadPDF } from '@documenso/lib/client-only/download-pdf'; import { formatDocumentsPath } from '@documenso/lib/utils/teams'; -import { DocumentStatus, RecipientRole } from '@documenso/prisma/client'; import type { Document, Recipient, Team, User } from '@documenso/prisma/client'; +import { DocumentStatus, RecipientRole } from '@documenso/prisma/client'; import { trpc as trpcClient } from '@documenso/trpc/client'; import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button'; import { @@ -190,6 +190,7 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr {canManageDocument && ( e.preventDefault()}>
diff --git a/apps/web/src/app/(dashboard)/documents/data-table.tsx b/apps/web/src/app/(dashboard)/documents/data-table.tsx index 4051b7d1d..71cbadb09 100644 --- a/apps/web/src/app/(dashboard)/documents/data-table.tsx +++ b/apps/web/src/app/(dashboard)/documents/data-table.tsx @@ -66,6 +66,7 @@ export const DocumentsDataTable = ({ ), }, diff --git a/apps/web/src/app/(dashboard)/templates/[id]/template-page-view-documents-table.tsx b/apps/web/src/app/(dashboard)/templates/[id]/template-page-view-documents-table.tsx index 4b4b1e57b..53221d084 100644 --- a/apps/web/src/app/(dashboard)/templates/[id]/template-page-view-documents-table.tsx +++ b/apps/web/src/app/(dashboard)/templates/[id]/template-page-view-documents-table.tsx @@ -117,6 +117,7 @@ export const TemplatePageViewDocumentsTable = ({ ), }, diff --git a/apps/web/src/components/(dashboard)/avatar/avatar-with-recipient.tsx b/apps/web/src/components/(dashboard)/avatar/avatar-with-recipient.tsx index 9f29bb06a..8f22395a5 100644 --- a/apps/web/src/components/(dashboard)/avatar/avatar-with-recipient.tsx +++ b/apps/web/src/components/(dashboard)/avatar/avatar-with-recipient.tsx @@ -1,7 +1,5 @@ 'use client'; -import React from 'react'; - import { msg } from '@lingui/macro'; import { useLingui } from '@lingui/react'; @@ -12,6 +10,7 @@ import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient- import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'; import type { Recipient } from '@documenso/prisma/client'; import { DocumentStatus } from '@documenso/prisma/client'; +import { trpc } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; import { useToast } from '@documenso/ui/primitives/use-toast'; @@ -20,14 +19,21 @@ import { StackAvatar } from './stack-avatar'; export type AvatarWithRecipientProps = { recipient: Recipient; documentStatus: DocumentStatus; + documentId: number; }; -export function AvatarWithRecipient({ recipient, documentStatus }: AvatarWithRecipientProps) { +export function AvatarWithRecipient({ + recipient, + documentStatus, + documentId, +}: AvatarWithRecipientProps) { const [, copy] = useCopyToClipboard(); const { _ } = useLingui(); const { toast } = useToast(); + const { mutateAsync: createAuditLog } = trpc.document.createAuditLog.useMutation(); + const signingToken = documentStatus === DocumentStatus.PENDING ? recipient.token : null; const onRecipientClick = () => { @@ -40,6 +46,18 @@ export function AvatarWithRecipient({ recipient, documentStatus }: AvatarWithRec title: _(msg`Copied to clipboard`), description: _(msg`The signing link has been copied to your clipboard.`), }); + + void createAuditLog({ + documentId, + type: 'DOCUMENT_SIGNING_LINK_COPIED', + data: { + recipientEmail: recipient.email, + recipientName: recipient.name, + recipientId: recipient.id, + recipientRole: recipient.role, + isBulkCopy: false, + }, + }); }); }; 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 bccee558e..58956687a 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 @@ -20,6 +20,7 @@ export type StackAvatarsWithTooltipProps = { recipients: Recipient[]; position?: 'top' | 'bottom'; children?: React.ReactNode; + documentId: number; }; export const StackAvatarsWithTooltip = ({ @@ -27,6 +28,7 @@ export const StackAvatarsWithTooltip = ({ recipients, position, children, + documentId, }: StackAvatarsWithTooltipProps) => { const { _ } = useLingui(); @@ -129,6 +131,7 @@ export const StackAvatarsWithTooltip = ({ key={recipient.id} recipient={recipient} documentStatus={documentStatus} + documentId={documentId} /> ))}
@@ -144,6 +147,7 @@ export const StackAvatarsWithTooltip = ({ key={recipient.id} recipient={recipient} documentStatus={documentStatus} + documentId={documentId} /> ))}
@@ -159,6 +163,7 @@ export const StackAvatarsWithTooltip = ({ key={recipient.id} recipient={recipient} documentStatus={documentStatus} + documentId={documentId} /> ))} diff --git a/apps/web/src/components/document/document-recipient-link-copy-dialog.tsx b/apps/web/src/components/document/document-recipient-link-copy-dialog.tsx index bec368f4c..6dbda5020 100644 --- a/apps/web/src/components/document/document-recipient-link-copy-dialog.tsx +++ b/apps/web/src/components/document/document-recipient-link-copy-dialog.tsx @@ -14,6 +14,7 @@ import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient- import { formatSigningLink } from '@documenso/lib/utils/recipients'; import type { Recipient } from '@documenso/prisma/client'; import { RecipientRole } from '@documenso/prisma/client'; +import { trpc } from '@documenso/trpc/react'; import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button'; import { AvatarWithText } from '@documenso/ui/primitives/avatar'; import { Button } from '@documenso/ui/primitives/button'; @@ -32,11 +33,13 @@ import { useToast } from '@documenso/ui/primitives/use-toast'; export type DocumentRecipientLinkCopyDialogProps = { trigger?: React.ReactNode; recipients: Recipient[]; + documentId: number; }; export const DocumentRecipientLinkCopyDialog = ({ trigger, recipients, + documentId, }: DocumentRecipientLinkCopyDialogProps) => { const { _ } = useLingui(); const { toast } = useToast(); @@ -50,6 +53,29 @@ export const DocumentRecipientLinkCopyDialog = ({ const actionSearchParam = searchParams?.get('action'); + const { mutateAsync: createAuditLog } = trpc.document.createAuditLog.useMutation(); + + const onCopyLink = async (recipient: Recipient) => { + await copy(formatSigningLink(recipient.token)).then(() => { + toast({ + title: _(msg`Copied to clipboard`), + description: _(msg`The signing link has been copied to your clipboard.`), + }); + + void createAuditLog({ + documentId, + type: 'DOCUMENT_SIGNING_LINK_COPIED', + data: { + recipientEmail: recipient.email, + recipientName: recipient.name, + recipientId: recipient.id, + recipientRole: recipient.role, + isBulkCopy: false, + }, + }); + }); + }; + const onBulkCopy = async () => { const generatedString = recipients .filter((recipient) => recipient.role !== RecipientRole.CC) @@ -61,6 +87,24 @@ export const DocumentRecipientLinkCopyDialog = ({ title: _(msg`Copied to clipboard`), description: _(msg`All signing links have been copied to your clipboard.`), }); + + void Promise.all( + recipients + .filter((recipient) => recipient.role !== RecipientRole.CC) + .map(async (recipient) => + createAuditLog({ + documentId, + type: 'DOCUMENT_SIGNING_LINK_COPIED', + data: { + recipientEmail: recipient.email, + recipientName: recipient.name, + recipientId: recipient.id, + recipientRole: recipient.role, + isBulkCopy: true, + }, + }), + ), + ); }); }; @@ -112,12 +156,7 @@ export const DocumentRecipientLinkCopyDialog = ({ {recipient.role !== RecipientRole.CC && ( { - toast({ - title: _(msg`Copied to clipboard`), - description: _(msg`The signing link has been copied to your clipboard.`), - }); - }} + onCopySuccess={async () => onCopyLink(recipient)} badgeContentUncopied={

Copy diff --git a/packages/lib/types/document-audit-logs.ts b/packages/lib/types/document-audit-logs.ts index 73073f7a8..28aea31b8 100644 --- a/packages/lib/types/document-audit-logs.ts +++ b/packages/lib/types/document-audit-logs.ts @@ -39,6 +39,7 @@ export const ZDocumentAuditLogTypeSchema = z.enum([ 'DOCUMENT_TITLE_UPDATED', // When the document title is updated. 'DOCUMENT_EXTERNAL_ID_UPDATED', // When the document external ID is updated. 'DOCUMENT_MOVED_TO_TEAM', // When the document is moved to a team. + 'DOCUMENT_SIGNING_LINK_COPIED', // When a signing link is copied. ]); export const ZDocumentAuditLogEmailTypeSchema = z.enum([ @@ -225,6 +226,16 @@ export const ZDocumentAuditLogEventDocumentDeletedSchema = z.object({ }), }); +/** + * Event: Document signing link copied. + */ +export const ZDocumentAuditLogEventDocumentSigningLinkCopiedSchema = z.object({ + type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SIGNING_LINK_COPIED), + data: ZBaseRecipientDataSchema.extend({ + isBulkCopy: z.boolean(), + }), +}); + /** * Event: Document field inserted. */ @@ -490,6 +501,7 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and( ZDocumentAuditLogEventDocumentCompletedSchema, ZDocumentAuditLogEventDocumentCreatedSchema, ZDocumentAuditLogEventDocumentDeletedSchema, + ZDocumentAuditLogEventDocumentSigningLinkCopiedSchema, ZDocumentAuditLogEventDocumentMovedToTeamSchema, ZDocumentAuditLogEventDocumentFieldInsertedSchema, ZDocumentAuditLogEventDocumentFieldUninsertedSchema, diff --git a/packages/lib/utils/document-audit-logs.ts b/packages/lib/utils/document-audit-logs.ts index 339bf453b..c87595307 100644 --- a/packages/lib/utils/document-audit-logs.ts +++ b/packages/lib/utils/document-audit-logs.ts @@ -385,6 +385,10 @@ export const formatDocumentAuditLogAction = ( anonymous: msg`Document completed`, identified: msg`Document completed`, })) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SIGNING_LINK_COPIED }, () => ({ + anonymous: msg`Document signing link copied`, + identified: msg`${prefix} copied the document signing link`, + })) .exhaustive(); return { diff --git a/packages/trpc/server/document-router/router.ts b/packages/trpc/server/document-router/router.ts index daa180c3a..da60a6daf 100644 --- a/packages/trpc/server/document-router/router.ts +++ b/packages/trpc/server/document-router/router.ts @@ -25,6 +25,8 @@ import { sendDocument } from '@documenso/lib/server-only/document/send-document' import { updateDocument } from '@documenso/lib/server-only/document/update-document'; import { symmetricEncrypt } from '@documenso/lib/universal/crypto'; import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions'; +import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs'; +import { prisma } from '@documenso/prisma'; import { DocumentDataType, DocumentStatus } from '@documenso/prisma/client'; import { authenticatedProcedure, procedure, router } from '../trpc'; @@ -626,4 +628,37 @@ export const documentRouter = router({ url: `${NEXT_PUBLIC_WEBAPP_URL()}/__htmltopdf/certificate?d=${encrypted}`, }; }), + + createAuditLog: authenticatedProcedure + .input( + z.object({ + documentId: z.number(), + type: z.literal('DOCUMENT_SIGNING_LINK_COPIED'), + data: z.object({ + recipientEmail: z.string(), + recipientName: z.string(), + recipientId: z.number(), + recipientRole: z.string(), + isBulkCopy: z.boolean(), + }), + }), + ) + .mutation(async ({ input, ctx }) => { + const { documentId, type, data } = input; + + console.log('input', input); + console.log('copiedddd'); + + const auditLog = await prisma.documentAuditLog.create({ + data: createDocumentAuditLogData({ + type, + data, + documentId, + user: ctx.user, + metadata: ctx.metadata, + }), + }); + + return auditLog; + }), }); diff --git a/packages/ui/primitives/document-flow/add-subject.tsx b/packages/ui/primitives/document-flow/add-subject.tsx index 24b976c32..fcd029dbe 100644 --- a/packages/ui/primitives/document-flow/add-subject.tsx +++ b/packages/ui/primitives/document-flow/add-subject.tsx @@ -16,6 +16,7 @@ import { DocumentStatus, RecipientRole, } from '@documenso/prisma/client'; +import { trpc } from '@documenso/trpc/react'; import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper'; import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs'; @@ -58,6 +59,8 @@ export const AddSubjectFormPartial = ({ }: AddSubjectFormProps) => { const { _ } = useLingui(); + const { mutateAsync: createAuditLog } = trpc.document.createAuditLog.useMutation(); + const { register, handleSubmit, @@ -98,6 +101,25 @@ export const AddSubjectFormPartial = ({ const onFormSubmit = handleSubmit(onSubmit); const { currentStep, totalSteps, previousStep } = useStep(); + const onCopyLink = (recipient: Recipient) => { + toast({ + title: _(msg`Copied to clipboard`), + description: _(msg`The signing link has been copied to your clipboard.`), + }); + + void createAuditLog({ + documentId: document.id, + type: 'DOCUMENT_SIGNING_LINK_COPIED', + data: { + recipientEmail: recipient.email, + recipientName: recipient.name, + recipientId: recipient.id, + recipientRole: recipient.role, + isBulkCopy: false, + }, + }); + }; + return ( <> { - toast({ - title: _(msg`Copied to clipboard`), - description: _( - msg`The signing link has been copied to your clipboard.`, - ), - }); - }} + onCopySuccess={() => void onCopyLink(recipient)} badgeContentUncopied={

Copy