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 (
-
}
+ 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 && (
+
+ )}
+
+
+ );
+};
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 && }