mirror of
https://github.com/documenso/documenso.git
synced 2025-11-10 12:32:34 +10:00
Compare commits
1 Commits
v1.9.1-rc.
...
feat/docum
| Author | SHA1 | Date | |
|---|---|---|---|
| f9eeaf1db8 |
@ -52,7 +52,7 @@ export const DocumentPageViewInformation = ({
|
|||||||
}, [isMounted, document, locale, userId]);
|
}, [isMounted, document, locale, userId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="dark:bg-background text-foreground border-border bg-widget mt-6 flex flex-col rounded-xl border">
|
<section className="dark:bg-background text-foreground border-border bg-widget flex flex-col rounded-xl border">
|
||||||
<h1 className="px-4 py-3 font-medium">Information</h1>
|
<h1 className="px-4 py-3 font-medium">Information</h1>
|
||||||
|
|
||||||
<ul className="divide-y border-t">
|
<ul className="divide-y border-t">
|
||||||
|
|||||||
@ -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 (
|
||||||
|
<section className="dark:bg-background border-border bg-widget flex flex-col rounded-xl border">
|
||||||
|
<div className="flex flex-row items-center justify-between border-b px-4 py-3">
|
||||||
|
<h1 className="text-foreground font-medium">Recent activity</h1>
|
||||||
|
|
||||||
|
{/* Can add dropdown menu here for additional options. */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex h-full items-center justify-center py-16">
|
||||||
|
<Loader className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoadingError && (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center py-16">
|
||||||
|
<p className="text-foreground/80 text-sm">Unable to load document history</p>
|
||||||
|
<button
|
||||||
|
onClick={async () => refetch()}
|
||||||
|
className="text-foreground/70 hover:text-muted-foreground mt-2 text-sm"
|
||||||
|
>
|
||||||
|
Click here to retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AnimateGenericFadeInOut>
|
||||||
|
{data && (
|
||||||
|
<ul role="list" className="space-y-6 p-4">
|
||||||
|
{hasNextPage && (
|
||||||
|
<li className="relative flex gap-x-4">
|
||||||
|
<div className="absolute -bottom-6 left-0 top-0 flex w-6 justify-center">
|
||||||
|
<div className="w-px bg-gray-200" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-widget relative flex h-6 w-6 flex-none items-center justify-center">
|
||||||
|
<div className="bg-widget h-1.5 w-1.5 rounded-full ring-1 ring-gray-300 dark:ring-neutral-600" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={async () => fetchNextPage()}
|
||||||
|
className="text-foreground/70 hover:text-muted-foreground text-xs"
|
||||||
|
>
|
||||||
|
{isFetchingNextPage ? 'Loading...' : 'Load older activity'}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{documentAuditLogs.map((auditLog, auditLogIndex) => (
|
||||||
|
<li key={auditLog.id} className="relative flex gap-x-4">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
auditLogIndex === documentAuditLogs.length - 1 ? 'h-6' : '-bottom-6',
|
||||||
|
'absolute left-0 top-0 flex w-6 justify-center',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="bg-border w-px" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-widget text-foreground/40 relative flex h-6 w-6 flex-none items-center justify-center">
|
||||||
|
{match(auditLog.type)
|
||||||
|
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED, () => (
|
||||||
|
<div className="bg-widget rounded-full border border-gray-300 p-1 dark:border-neutral-600">
|
||||||
|
<CheckCheckIcon className="h-3 w-3" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED, () => (
|
||||||
|
<div className="bg-widget rounded-full border border-gray-300 p-1 dark:border-neutral-600">
|
||||||
|
<CheckIcon className="h-3 w-3" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED, () => (
|
||||||
|
<div className="bg-widget rounded-full border border-gray-300 p-1 dark:border-neutral-600">
|
||||||
|
<MailOpen className="h-3 w-3" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
.otherwise(() => (
|
||||||
|
<div className="bg-widget h-1.5 w-1.5 rounded-full ring-1 ring-gray-300 dark:ring-neutral-600" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground dark:text-muted-foreground/70 flex-auto py-0.5 text-xs leading-5">
|
||||||
|
<span className="text-foreground font-medium">
|
||||||
|
{formatDocumentAuditLogAction(auditLog, userId).prefix}
|
||||||
|
</span>{' '}
|
||||||
|
{formatDocumentAuditLogAction(auditLog, userId).description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<time className="text-muted-foreground dark:text-muted-foreground/70 flex-none py-0.5 text-xs leading-5">
|
||||||
|
{DateTime.fromJSDate(auditLog.createdAt).toRelative({ style: 'short' })}
|
||||||
|
</time>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</AnimateGenericFadeInOut>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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 (
|
||||||
|
<section className="dark:bg-background border-border bg-widget flex flex-col rounded-xl border">
|
||||||
|
<div className="flex flex-row items-center justify-between px-4 py-3">
|
||||||
|
<h1 className="text-foreground font-medium">Recipients</h1>
|
||||||
|
|
||||||
|
{document.status !== DocumentStatus.COMPLETED && (
|
||||||
|
<Link
|
||||||
|
href={`${documentRootPath}/${document.id}/edit?step=signers`}
|
||||||
|
title="Modify recipients"
|
||||||
|
className="flex flex-row items-center justify-between"
|
||||||
|
>
|
||||||
|
{recipients.length === 0 ? (
|
||||||
|
<PlusIcon className="ml-2 h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<PenIcon className="ml-2 h-3 w-3" />
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="text-muted-foreground divide-y border-t">
|
||||||
|
{recipients.length === 0 && (
|
||||||
|
<li className="flex flex-col items-center justify-center py-6 text-sm">No recipients</li>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{recipients.map((recipient) => (
|
||||||
|
<li key={recipient.id} className="flex items-center justify-between px-4 py-2.5 text-sm">
|
||||||
|
<AvatarWithText
|
||||||
|
avatarFallback={recipient.email.slice(0, 1).toUpperCase()}
|
||||||
|
primaryText={<p className="text-muted-foreground text-sm">{recipient.email}</p>}
|
||||||
|
secondaryText={
|
||||||
|
<p className="text-muted-foreground/70 text-xs">
|
||||||
|
{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{document.status !== DocumentStatus.DRAFT &&
|
||||||
|
recipient.signingStatus === SigningStatus.SIGNED && (
|
||||||
|
<Badge variant="default">
|
||||||
|
{match(recipient.role)
|
||||||
|
.with(RecipientRole.APPROVER, () => (
|
||||||
|
<>
|
||||||
|
<CheckIcon className="mr-1 h-3 w-3" />
|
||||||
|
Approved
|
||||||
|
</>
|
||||||
|
))
|
||||||
|
.with(RecipientRole.CC, () =>
|
||||||
|
document.status === DocumentStatus.COMPLETED ? (
|
||||||
|
<>
|
||||||
|
<MailIcon className="mr-1 h-3 w-3" />
|
||||||
|
Sent
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CheckIcon className="mr-1 h-3 w-3" />
|
||||||
|
Ready
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
.with(RecipientRole.SIGNER, () => (
|
||||||
|
<>
|
||||||
|
<SignatureIcon className="mr-1 h-3 w-3" />
|
||||||
|
Signed
|
||||||
|
</>
|
||||||
|
))
|
||||||
|
.with(RecipientRole.VIEWER, () => (
|
||||||
|
<>
|
||||||
|
<MailOpenIcon className="mr-1 h-3 w-3" />
|
||||||
|
Viewed
|
||||||
|
</>
|
||||||
|
))
|
||||||
|
.exhaustive()}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{document.status !== DocumentStatus.DRAFT &&
|
||||||
|
recipient.signingStatus === SigningStatus.NOT_SIGNED && (
|
||||||
|
<Badge variant="secondary">
|
||||||
|
<Clock className="mr-1 h-3 w-3" />
|
||||||
|
Pending
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,34 +1,23 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
import {
|
import { ChevronLeft, Clock9, Users2 } from 'lucide-react';
|
||||||
CheckIcon,
|
|
||||||
ChevronLeft,
|
|
||||||
Clock,
|
|
||||||
MailIcon,
|
|
||||||
MailOpenIcon,
|
|
||||||
PenIcon,
|
|
||||||
PlusIcon,
|
|
||||||
Users2,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
|
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 { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
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 { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
||||||
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
|
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
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 type { Team } from '@documenso/prisma/client';
|
||||||
import { SignatureIcon } from '@documenso/ui/icons/signature';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
|
||||||
import { Badge } from '@documenso/ui/primitives/badge';
|
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||||
|
|
||||||
import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
|
import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
|
||||||
|
import { DocumentHistorySheet } from '~/components/document/document-history-sheet';
|
||||||
import {
|
import {
|
||||||
DocumentStatus as DocumentStatusComponent,
|
DocumentStatus as DocumentStatusComponent,
|
||||||
FRIENDLY_STATUS_MAP,
|
FRIENDLY_STATUS_MAP,
|
||||||
@ -37,6 +26,8 @@ import {
|
|||||||
import { DocumentPageViewButton } from './document-page-view-button';
|
import { DocumentPageViewButton } from './document-page-view-button';
|
||||||
import { DocumentPageViewDropdown } from './document-page-view-dropdown';
|
import { DocumentPageViewDropdown } from './document-page-view-dropdown';
|
||||||
import { DocumentPageViewInformation } from './document-page-view-information';
|
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 = {
|
export type DocumentPageViewProps = {
|
||||||
params: {
|
params: {
|
||||||
@ -104,27 +95,38 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
|
|||||||
Documents
|
Documents
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div>
|
<div className="flex flex-row justify-between">
|
||||||
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
|
<div>
|
||||||
{document.title}
|
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
|
||||||
</h1>
|
{document.title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
<div className="mt-2.5 flex items-center gap-x-6">
|
<div className="mt-2.5 flex items-center gap-x-6">
|
||||||
<DocumentStatusComponent
|
<DocumentStatusComponent
|
||||||
inheritColor
|
inheritColor
|
||||||
status={document.status}
|
status={document.status}
|
||||||
className="text-muted-foreground"
|
className="text-muted-foreground"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{recipients.length > 0 && (
|
{recipients.length > 0 && (
|
||||||
<div className="text-muted-foreground flex items-center">
|
<div className="text-muted-foreground flex items-center">
|
||||||
<Users2 className="mr-2 h-5 w-5" />
|
<Users2 className="mr-2 h-5 w-5" />
|
||||||
|
|
||||||
<StackAvatarsWithTooltip recipients={recipients} position="bottom">
|
<StackAvatarsWithTooltip recipients={recipients} position="bottom">
|
||||||
<span>{recipients.length} Recipient(s)</span>
|
<span>{recipients.length} Recipient(s)</span>
|
||||||
</StackAvatarsWithTooltip>
|
</StackAvatarsWithTooltip>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="self-end">
|
||||||
|
<DocumentHistorySheet documentId={document.id} userId={user.id}>
|
||||||
|
<Button variant="outline">
|
||||||
|
<Clock9 className="mr-1.5 h-4 w-4" />
|
||||||
|
Document history
|
||||||
|
</Button>
|
||||||
|
</DocumentHistorySheet>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -139,8 +141,8 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
|
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
|
||||||
<div className="sticky top-20">
|
<div className="space-y-6">
|
||||||
<section className="dark:bg-background border-border bg-widget sticky flex flex-col rounded-xl border pb-4 pt-6">
|
<section className="border-border bg-widget flex flex-col rounded-xl border pb-4 pt-6">
|
||||||
<div className="flex flex-row items-center justify-between px-4">
|
<div className="flex flex-row items-center justify-between px-4">
|
||||||
<h3 className="text-foreground text-2xl font-semibold">
|
<h3 className="text-foreground text-2xl font-semibold">
|
||||||
Document {FRIENDLY_STATUS_MAP[document.status].label.toLowerCase()}
|
Document {FRIENDLY_STATUS_MAP[document.status].label.toLowerCase()}
|
||||||
@ -180,100 +182,13 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
|
|||||||
<DocumentPageViewInformation document={documentWithRecipients} userId={user.id} />
|
<DocumentPageViewInformation document={documentWithRecipients} userId={user.id} />
|
||||||
|
|
||||||
{/* Recipients section. */}
|
{/* Recipients section. */}
|
||||||
<section className="dark:bg-background border-border bg-widget mt-6 flex flex-col rounded-xl border">
|
<DocumentPageViewRecipients
|
||||||
<div className="flex flex-row items-center justify-between px-4 py-3">
|
document={documentWithRecipients}
|
||||||
<h1 className="text-foreground font-medium">Recipients</h1>
|
documentRootPath={documentRootPath}
|
||||||
|
/>
|
||||||
|
|
||||||
{document.status !== DocumentStatus.COMPLETED && (
|
{/* Recent activity section. */}
|
||||||
<Link
|
<DocumentPageViewRecentActivity documentId={document.id} userId={user.id} />
|
||||||
href={`${documentRootPath}/${document.id}/edit?step=signers`}
|
|
||||||
title="Modify recipients"
|
|
||||||
className="flex flex-row items-center justify-between"
|
|
||||||
>
|
|
||||||
{recipients.length === 0 ? (
|
|
||||||
<PlusIcon className="ml-2 h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<PenIcon className="ml-2 h-3 w-3" />
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ul className="text-muted-foreground divide-y border-t">
|
|
||||||
{recipients.length === 0 && (
|
|
||||||
<li className="flex flex-col items-center justify-center py-6 text-sm">
|
|
||||||
No recipients
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{recipients.map((recipient) => (
|
|
||||||
<li
|
|
||||||
key={recipient.id}
|
|
||||||
className="flex items-center justify-between px-4 py-2.5 text-sm"
|
|
||||||
>
|
|
||||||
<AvatarWithText
|
|
||||||
avatarFallback={recipient.email.slice(0, 1).toUpperCase()}
|
|
||||||
primaryText={
|
|
||||||
<p className="text-muted-foreground text-sm">{recipient.email}</p>
|
|
||||||
}
|
|
||||||
secondaryText={
|
|
||||||
<p className="text-muted-foreground/70 text-xs">
|
|
||||||
{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{document.status !== DocumentStatus.DRAFT &&
|
|
||||||
recipient.signingStatus === SigningStatus.SIGNED && (
|
|
||||||
<Badge variant="default">
|
|
||||||
{match(recipient.role)
|
|
||||||
.with(RecipientRole.APPROVER, () => (
|
|
||||||
<>
|
|
||||||
<CheckIcon className="mr-1 h-3 w-3" />
|
|
||||||
Approved
|
|
||||||
</>
|
|
||||||
))
|
|
||||||
.with(RecipientRole.CC, () =>
|
|
||||||
document.status === DocumentStatus.COMPLETED ? (
|
|
||||||
<>
|
|
||||||
<MailIcon className="mr-1 h-3 w-3" />
|
|
||||||
Sent
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<CheckIcon className="mr-1 h-3 w-3" />
|
|
||||||
Ready
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
.with(RecipientRole.SIGNER, () => (
|
|
||||||
<>
|
|
||||||
<SignatureIcon className="mr-1 h-3 w-3" />
|
|
||||||
Signed
|
|
||||||
</>
|
|
||||||
))
|
|
||||||
.with(RecipientRole.VIEWER, () => (
|
|
||||||
<>
|
|
||||||
<MailOpenIcon className="mr-1 h-3 w-3" />
|
|
||||||
Viewed
|
|
||||||
</>
|
|
||||||
))
|
|
||||||
.exhaustive()}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{document.status !== DocumentStatus.DRAFT &&
|
|
||||||
recipient.signingStatus === SigningStatus.NOT_SIGNED && (
|
|
||||||
<Badge variant="secondary">
|
|
||||||
<Clock className="mr-1 h-3 w-3" />
|
|
||||||
Pending
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -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 (
|
||||||
|
<Badge
|
||||||
|
className="text-muted-foreground mt-3 block w-full space-y-0.5 text-xs"
|
||||||
|
variant="neutral"
|
||||||
|
>
|
||||||
|
{values.map(({ key, value }, i) => (
|
||||||
|
<p key={typeof key === 'string' ? key : i}>
|
||||||
|
<span>{key}: </span>
|
||||||
|
<span className="font-normal">{value}</span>
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
};
|
||||||
316
apps/web/src/components/document/document-history-sheet.tsx
Normal file
316
apps/web/src/components/document/document-history-sheet.tsx
Normal file
@ -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 (
|
||||||
|
<Sheet open={isMenuOpen} onOpenChange={onMenuOpenChange}>
|
||||||
|
{children && <SheetTrigger asChild>{children}</SheetTrigger>}
|
||||||
|
|
||||||
|
<SheetContent
|
||||||
|
sheetClass="backdrop-blur-none"
|
||||||
|
className="flex w-full max-w-[500px] flex-col overflow-y-auto p-0"
|
||||||
|
>
|
||||||
|
<div className="text-foreground px-6 pt-6">
|
||||||
|
<h1 className="text-lg font-medium">Document history</h1>
|
||||||
|
<button
|
||||||
|
className="text-muted-foreground text-sm"
|
||||||
|
onClick={() => setIsUserDetailsVisible(!isUserDetailsVisible)}
|
||||||
|
>
|
||||||
|
{isUserDetailsVisible ? 'Hide' : 'Show'} additional information
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<Loader className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoadingError && (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center">
|
||||||
|
<p className="text-foreground/80 text-sm">Unable to load document history</p>
|
||||||
|
<button
|
||||||
|
onClick={async () => refetch()}
|
||||||
|
className="text-foreground/70 hover:text-muted-foreground mt-2 text-sm"
|
||||||
|
>
|
||||||
|
Click here to retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data && (
|
||||||
|
<ul
|
||||||
|
className={cn('divide-y border-t', {
|
||||||
|
'mb-4 border-b': !hasNextPage,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{documentAuditLogs.map((auditLog) => (
|
||||||
|
<li className="px-4 py-2.5" key={auditLog.id}>
|
||||||
|
<div className="flex flex-row items-center">
|
||||||
|
<Avatar className="mr-2 h-9 w-9">
|
||||||
|
<AvatarFallback className="text-xs text-gray-400">
|
||||||
|
{(auditLog?.email ?? auditLog?.name ?? '?').slice(0, 1).toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-foreground text-xs font-bold">
|
||||||
|
{formatDocumentAuditLogActionString(auditLog, userId)}
|
||||||
|
</p>
|
||||||
|
<p className="text-foreground/50 text-xs">
|
||||||
|
<LocaleDate date={auditLog.createdAt} format="d MMM, yyyy HH:MM a" />
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{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 <DocumentHistorySheetChanges values={values} />;
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED }, ({ data }) => {
|
||||||
|
if (data.changes.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DocumentHistorySheetChanges
|
||||||
|
values={data.changes.map(({ type, from, to }) => ({
|
||||||
|
key: formatGenericText(type),
|
||||||
|
value: (
|
||||||
|
<span className="inline-flex flex-row items-center">
|
||||||
|
<span>{type === 'ROLE' ? formatGenericText(from) : from}</span>
|
||||||
|
<ArrowRightIcon className="h-4 w-4" />
|
||||||
|
<span>{type === 'ROLE' ? formatGenericText(to) : to}</span>
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.with(
|
||||||
|
{ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_CREATED },
|
||||||
|
{ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_DELETED },
|
||||||
|
{ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_UPDATED },
|
||||||
|
({ data }) => (
|
||||||
|
<DocumentHistorySheetChanges
|
||||||
|
values={[
|
||||||
|
{
|
||||||
|
key: 'Field',
|
||||||
|
value: formatGenericText(data.fieldType),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'Recipient',
|
||||||
|
value: formatGenericText(data.fieldRecipientEmail),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED }, ({ data }) => {
|
||||||
|
if (data.changes.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DocumentHistorySheetChanges
|
||||||
|
values={data.changes.map((change) => ({
|
||||||
|
key: formatGenericText(change.type),
|
||||||
|
value: change.type === 'PASSWORD' ? '*********' : change.to,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_TITLE_UPDATED }, ({ data }) => (
|
||||||
|
<DocumentHistorySheetChanges
|
||||||
|
values={[
|
||||||
|
{
|
||||||
|
key: 'Old',
|
||||||
|
value: data.from,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'New',
|
||||||
|
value: data.to,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED }, ({ data }) => (
|
||||||
|
<DocumentHistorySheetChanges
|
||||||
|
values={[
|
||||||
|
{
|
||||||
|
key: 'Field inserted',
|
||||||
|
value: formatGenericText(data.field.type),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_UNINSERTED }, ({ data }) => (
|
||||||
|
<DocumentHistorySheetChanges
|
||||||
|
values={[
|
||||||
|
{
|
||||||
|
key: 'Field uninserted',
|
||||||
|
value: formatGenericText(data.field),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT }, ({ data }) => (
|
||||||
|
<DocumentHistorySheetChanges
|
||||||
|
values={[
|
||||||
|
{
|
||||||
|
key: 'Type',
|
||||||
|
value: DOCUMENT_AUDIT_LOG_EMAIL_FORMAT[data.emailType].description,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'Sent to',
|
||||||
|
value: data.recipientEmail,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
.exhaustive()}
|
||||||
|
|
||||||
|
{isUserDetailsVisible && (
|
||||||
|
<>
|
||||||
|
<div className="mb-1 mt-2 flex flex-row space-x-2">
|
||||||
|
<Badge variant="neutral" className="text-muted-foreground">
|
||||||
|
IP: {auditLog.ipAddress ?? 'Unknown'}
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
<Badge variant="neutral" className="text-muted-foreground">
|
||||||
|
Browser: {extractBrowser(auditLog.userAgent)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{hasNextPage && (
|
||||||
|
<div className="flex items-center justify-center py-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
loading={isFetchingNextPage}
|
||||||
|
onClick={async () => fetchNextPage()}
|
||||||
|
>
|
||||||
|
Show more
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { HTMLAttributes } from 'react';
|
import type { HTMLAttributes } from 'react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
import type { DateTimeFormatOptions } from 'luxon';
|
import type { DateTimeFormatOptions } from 'luxon';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
@ -10,7 +10,7 @@ import { useLocale } from '@documenso/lib/client-only/providers/locale';
|
|||||||
|
|
||||||
export type LocaleDateProps = HTMLAttributes<HTMLSpanElement> & {
|
export type LocaleDateProps = HTMLAttributes<HTMLSpanElement> & {
|
||||||
date: string | number | Date;
|
date: string | number | Date;
|
||||||
format?: DateTimeFormatOptions;
|
format?: DateTimeFormatOptions | string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -22,13 +22,24 @@ export type LocaleDateProps = HTMLAttributes<HTMLSpanElement> & {
|
|||||||
export const LocaleDate = ({ className, date, format, ...props }: LocaleDateProps) => {
|
export const LocaleDate = ({ className, date, format, ...props }: LocaleDateProps) => {
|
||||||
const { locale } = useLocale();
|
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(() =>
|
const [localeDate, setLocaleDate] = useState(() =>
|
||||||
DateTime.fromJSDate(new Date(date)).setLocale(locale).toLocaleString(format),
|
formatDateTime(DateTime.fromJSDate(new Date(date)).setLocale(locale)),
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLocaleDate(DateTime.fromJSDate(new Date(date)).toLocaleString(format));
|
setLocaleDate(formatDateTime(DateTime.fromJSDate(new Date(date))));
|
||||||
}, [date, format]);
|
}, [date, format, formatDateTime]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className={className} {...props}>
|
<span className={className} {...props}>
|
||||||
|
|||||||
19
packages/lib/constants/document-audit-logs.ts
Normal file
19
packages/lib/constants/document-audit-logs.ts
Normal file
@ -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<keyof typeof DOCUMENT_EMAIL_TYPE, unknown>;
|
||||||
@ -1,29 +1,31 @@
|
|||||||
import { RecipientRole } from '@documenso/prisma/client';
|
import { RecipientRole } from '@documenso/prisma/client';
|
||||||
|
|
||||||
export const RECIPIENT_ROLES_DESCRIPTION: {
|
export const RECIPIENT_ROLES_DESCRIPTION = {
|
||||||
[key in RecipientRole]: { actionVerb: string; progressiveVerb: string; roleName: string };
|
|
||||||
} = {
|
|
||||||
[RecipientRole.APPROVER]: {
|
[RecipientRole.APPROVER]: {
|
||||||
actionVerb: 'Approve',
|
actionVerb: 'Approve',
|
||||||
|
actioned: 'Approved',
|
||||||
progressiveVerb: 'Approving',
|
progressiveVerb: 'Approving',
|
||||||
roleName: 'Approver',
|
roleName: 'Approver',
|
||||||
},
|
},
|
||||||
[RecipientRole.CC]: {
|
[RecipientRole.CC]: {
|
||||||
actionVerb: 'CC',
|
actionVerb: 'CC',
|
||||||
|
actioned: 'CCed',
|
||||||
progressiveVerb: 'CC',
|
progressiveVerb: 'CC',
|
||||||
roleName: 'Cc',
|
roleName: 'Cc',
|
||||||
},
|
},
|
||||||
[RecipientRole.SIGNER]: {
|
[RecipientRole.SIGNER]: {
|
||||||
actionVerb: 'Sign',
|
actionVerb: 'Sign',
|
||||||
|
actioned: 'Signed',
|
||||||
progressiveVerb: 'Signing',
|
progressiveVerb: 'Signing',
|
||||||
roleName: 'Signer',
|
roleName: 'Signer',
|
||||||
},
|
},
|
||||||
[RecipientRole.VIEWER]: {
|
[RecipientRole.VIEWER]: {
|
||||||
actionVerb: 'View',
|
actionVerb: 'View',
|
||||||
|
actioned: 'Viewed',
|
||||||
progressiveVerb: 'Viewing',
|
progressiveVerb: 'Viewing',
|
||||||
roleName: 'Viewer',
|
roleName: 'Viewer',
|
||||||
},
|
},
|
||||||
};
|
} satisfies Record<keyof typeof RecipientRole, unknown>;
|
||||||
|
|
||||||
export const RECIPIENT_ROLE_TO_EMAIL_TYPE = {
|
export const RECIPIENT_ROLE_TO_EMAIL_TYPE = {
|
||||||
[RecipientRole.SIGNER]: 'SIGNING_REQUEST',
|
[RecipientRole.SIGNER]: 'SIGNING_REQUEST',
|
||||||
|
|||||||
@ -89,17 +89,21 @@ export const upsertDocumentMeta = async ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await tx.documentAuditLog.create({
|
const changes = diffDocumentMetaChanges(originalDocumentMeta ?? {}, upsertedDocumentMeta);
|
||||||
data: createDocumentAuditLogData({
|
|
||||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED,
|
if (changes.length > 0) {
|
||||||
documentId,
|
await tx.documentAuditLog.create({
|
||||||
user,
|
data: createDocumentAuditLogData({
|
||||||
requestMetadata,
|
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED,
|
||||||
data: {
|
documentId,
|
||||||
changes: diffDocumentMetaChanges(originalDocumentMeta ?? {}, upsertedDocumentMeta),
|
user,
|
||||||
},
|
requestMetadata,
|
||||||
}),
|
data: {
|
||||||
});
|
changes: diffDocumentMetaChanges(originalDocumentMeta ?? {}, upsertedDocumentMeta),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return upsertedDocumentMeta;
|
return upsertedDocumentMeta;
|
||||||
});
|
});
|
||||||
|
|||||||
@ -9,27 +9,72 @@ import { prisma } from '@documenso/prisma';
|
|||||||
import { DocumentStatus } from '@documenso/prisma/client';
|
import { DocumentStatus } from '@documenso/prisma/client';
|
||||||
|
|
||||||
import { FROM_ADDRESS, FROM_NAME } from '../../constants/email';
|
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 = {
|
export type DeleteDocumentOptions = {
|
||||||
id: number;
|
id: number;
|
||||||
userId: number;
|
userId: number;
|
||||||
status: DocumentStatus;
|
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 the document is a draft, hard-delete
|
||||||
if (status === DocumentStatus.DRAFT) {
|
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 the document is pending, send cancellation emails to all recipients
|
||||||
if (status === DocumentStatus.PENDING) {
|
if (status === DocumentStatus.PENDING) {
|
||||||
const user = await prisma.user.findFirstOrThrow({
|
|
||||||
where: {
|
|
||||||
id: userId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const document = await prisma.document.findUnique({
|
const document = await prisma.document.findUnique({
|
||||||
where: {
|
where: {
|
||||||
id,
|
id,
|
||||||
@ -77,12 +122,26 @@ export const deleteDocument = async ({ id, userId, status }: DeleteDocumentOptio
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If the document is not a draft, only soft-delete.
|
// If the document is not a draft, only soft-delete.
|
||||||
return await prisma.document.update({
|
return await prisma.$transaction(async (tx) => {
|
||||||
where: {
|
await tx.documentAuditLog.create({
|
||||||
id,
|
data: createDocumentAuditLogData({
|
||||||
},
|
documentId: id,
|
||||||
data: {
|
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED,
|
||||||
deletedAt: new Date().toISOString(),
|
user,
|
||||||
},
|
requestMetadata,
|
||||||
|
data: {
|
||||||
|
type: 'SOFT',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return await tx.document.update({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
deletedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
115
packages/lib/server-only/document/find-document-audit-logs.ts
Normal file
115
packages/lib/server-only/document/find-document-audit-logs.ts
Normal file
@ -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<typeof parsedData> & { nextCursor?: string };
|
||||||
|
};
|
||||||
@ -152,13 +152,27 @@ export const sendDocument = async ({
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const updatedDocument = await prisma.document.update({
|
const updatedDocument = await prisma.$transaction(async (tx) => {
|
||||||
where: {
|
if (document.status === DocumentStatus.DRAFT) {
|
||||||
id: documentId,
|
await tx.documentAuditLog.create({
|
||||||
},
|
data: createDocumentAuditLogData({
|
||||||
data: {
|
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT,
|
||||||
status: DocumentStatus.PENDING,
|
documentId: document.id,
|
||||||
},
|
requestMetadata,
|
||||||
|
user,
|
||||||
|
data: {},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return await tx.document.update({
|
||||||
|
where: {
|
||||||
|
id: documentId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
status: DocumentStatus.PENDING,
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return updatedDocument;
|
return updatedDocument;
|
||||||
|
|||||||
@ -21,15 +21,24 @@ export const ZDocumentAuditLogTypeSchema = z.enum([
|
|||||||
'RECIPIENT_UPDATED',
|
'RECIPIENT_UPDATED',
|
||||||
|
|
||||||
// Document events.
|
// 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_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([
|
export const ZDocumentMetaDiffTypeSchema = z.enum([
|
||||||
@ -40,10 +49,12 @@ export const ZDocumentMetaDiffTypeSchema = z.enum([
|
|||||||
'SUBJECT',
|
'SUBJECT',
|
||||||
'TIMEZONE',
|
'TIMEZONE',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const ZFieldDiffTypeSchema = z.enum(['DIMENSION', 'POSITION']);
|
export const ZFieldDiffTypeSchema = z.enum(['DIMENSION', 'POSITION']);
|
||||||
export const ZRecipientDiffTypeSchema = z.enum(['NAME', 'ROLE', 'EMAIL']);
|
export const ZRecipientDiffTypeSchema = z.enum(['NAME', 'ROLE', 'EMAIL']);
|
||||||
|
|
||||||
export const DOCUMENT_AUDIT_LOG_TYPE = ZDocumentAuditLogTypeSchema.Enum;
|
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 DOCUMENT_META_DIFF_TYPE = ZDocumentMetaDiffTypeSchema.Enum;
|
||||||
export const FIELD_DIFF_TYPE = ZFieldDiffTypeSchema.Enum;
|
export const FIELD_DIFF_TYPE = ZFieldDiffTypeSchema.Enum;
|
||||||
export const RECIPIENT_DIFF_TYPE = ZRecipientDiffTypeSchema.Enum;
|
export const RECIPIENT_DIFF_TYPE = ZRecipientDiffTypeSchema.Enum;
|
||||||
@ -140,13 +151,7 @@ const ZBaseRecipientDataSchema = z.object({
|
|||||||
export const ZDocumentAuditLogEventEmailSentSchema = z.object({
|
export const ZDocumentAuditLogEventEmailSentSchema = z.object({
|
||||||
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT),
|
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT),
|
||||||
data: ZBaseRecipientDataSchema.extend({
|
data: ZBaseRecipientDataSchema.extend({
|
||||||
emailType: z.enum([
|
emailType: ZDocumentAuditLogEmailTypeSchema,
|
||||||
'SIGNING_REQUEST',
|
|
||||||
'VIEW_REQUEST',
|
|
||||||
'APPROVE_REQUEST',
|
|
||||||
'CC',
|
|
||||||
'DOCUMENT_COMPLETED',
|
|
||||||
]),
|
|
||||||
isResending: z.boolean(),
|
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.
|
* Event: Document field inserted.
|
||||||
*/
|
*/
|
||||||
@ -247,6 +262,14 @@ export const ZDocumentAuditLogEventDocumentRecipientCompleteSchema = z.object({
|
|||||||
data: ZBaseRecipientDataSchema,
|
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.
|
* Event: Document title updated.
|
||||||
*/
|
*/
|
||||||
@ -314,6 +337,11 @@ export const ZDocumentAuditLogBaseSchema = z.object({
|
|||||||
id: z.string(),
|
id: z.string(),
|
||||||
createdAt: z.date(),
|
createdAt: z.date(),
|
||||||
documentId: z.number(),
|
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(
|
export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
|
||||||
@ -321,11 +349,13 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
|
|||||||
ZDocumentAuditLogEventEmailSentSchema,
|
ZDocumentAuditLogEventEmailSentSchema,
|
||||||
ZDocumentAuditLogEventDocumentCompletedSchema,
|
ZDocumentAuditLogEventDocumentCompletedSchema,
|
||||||
ZDocumentAuditLogEventDocumentCreatedSchema,
|
ZDocumentAuditLogEventDocumentCreatedSchema,
|
||||||
|
ZDocumentAuditLogEventDocumentDeletedSchema,
|
||||||
ZDocumentAuditLogEventDocumentFieldInsertedSchema,
|
ZDocumentAuditLogEventDocumentFieldInsertedSchema,
|
||||||
ZDocumentAuditLogEventDocumentFieldUninsertedSchema,
|
ZDocumentAuditLogEventDocumentFieldUninsertedSchema,
|
||||||
ZDocumentAuditLogEventDocumentMetaUpdatedSchema,
|
ZDocumentAuditLogEventDocumentMetaUpdatedSchema,
|
||||||
ZDocumentAuditLogEventDocumentOpenedSchema,
|
ZDocumentAuditLogEventDocumentOpenedSchema,
|
||||||
ZDocumentAuditLogEventDocumentRecipientCompleteSchema,
|
ZDocumentAuditLogEventDocumentRecipientCompleteSchema,
|
||||||
|
ZDocumentAuditLogEventDocumentSentSchema,
|
||||||
ZDocumentAuditLogEventDocumentTitleUpdatedSchema,
|
ZDocumentAuditLogEventDocumentTitleUpdatedSchema,
|
||||||
ZDocumentAuditLogEventFieldCreatedSchema,
|
ZDocumentAuditLogEventFieldCreatedSchema,
|
||||||
ZDocumentAuditLogEventFieldRemovedSchema,
|
ZDocumentAuditLogEventFieldRemovedSchema,
|
||||||
@ -348,3 +378,8 @@ export type TDocumentAuditLogDocumentMetaDiffSchema = z.infer<
|
|||||||
export type TDocumentAuditLogRecipientDiffSchema = z.infer<
|
export type TDocumentAuditLogRecipientDiffSchema = z.infer<
|
||||||
typeof ZDocumentAuditLogRecipientDiffSchema
|
typeof ZDocumentAuditLogRecipientDiffSchema
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
export type DocumentAuditLogByType<T = TDocumentAuditLog['type']> = Extract<
|
||||||
|
TDocumentAuditLog,
|
||||||
|
{ type: T }
|
||||||
|
>;
|
||||||
|
|||||||
@ -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 {
|
import type {
|
||||||
TDocumentAuditLog,
|
TDocumentAuditLog,
|
||||||
TDocumentAuditLogDocumentMetaDiffSchema,
|
TDocumentAuditLogDocumentMetaDiffSchema,
|
||||||
@ -7,6 +16,7 @@ import type {
|
|||||||
TDocumentAuditLogRecipientDiffSchema,
|
TDocumentAuditLogRecipientDiffSchema,
|
||||||
} from '../types/document-audit-logs';
|
} from '../types/document-audit-logs';
|
||||||
import {
|
import {
|
||||||
|
DOCUMENT_AUDIT_LOG_TYPE,
|
||||||
DOCUMENT_META_DIFF_TYPE,
|
DOCUMENT_META_DIFF_TYPE,
|
||||||
FIELD_DIFF_TYPE,
|
FIELD_DIFF_TYPE,
|
||||||
RECIPIENT_DIFF_TYPE,
|
RECIPIENT_DIFF_TYPE,
|
||||||
@ -58,6 +68,7 @@ export const parseDocumentAuditLogData = (auditLog: DocumentAuditLog): TDocument
|
|||||||
|
|
||||||
// Handle any required migrations here.
|
// Handle any required migrations here.
|
||||||
if (!data.success) {
|
if (!data.success) {
|
||||||
|
console.error(data.error);
|
||||||
throw new Error('Migration required');
|
throw new Error('Migration required');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -203,3 +214,114 @@ export const diffDocumentMetaChanges = (
|
|||||||
|
|
||||||
return diffs;
|
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,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@ -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 { createDocument } from '@documenso/lib/server-only/document/create-document';
|
||||||
import { deleteDocument } from '@documenso/lib/server-only/document/delete-document';
|
import { deleteDocument } from '@documenso/lib/server-only/document/delete-document';
|
||||||
import { duplicateDocumentById } from '@documenso/lib/server-only/document/duplicate-document-by-id';
|
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 { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
||||||
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||||
import { resendDocument } from '@documenso/lib/server-only/document/resend-document';
|
import { resendDocument } from '@documenso/lib/server-only/document/resend-document';
|
||||||
@ -21,6 +22,7 @@ import { authenticatedProcedure, procedure, router } from '../trpc';
|
|||||||
import {
|
import {
|
||||||
ZCreateDocumentMutationSchema,
|
ZCreateDocumentMutationSchema,
|
||||||
ZDeleteDraftDocumentMutationSchema,
|
ZDeleteDraftDocumentMutationSchema,
|
||||||
|
ZFindDocumentAuditLogsQuerySchema,
|
||||||
ZGetDocumentByIdQuerySchema,
|
ZGetDocumentByIdQuerySchema,
|
||||||
ZGetDocumentByTokenQuerySchema,
|
ZGetDocumentByTokenQuerySchema,
|
||||||
ZResendDocumentMutationSchema,
|
ZResendDocumentMutationSchema,
|
||||||
@ -111,7 +113,12 @@ export const documentRouter = router({
|
|||||||
|
|
||||||
const userId = ctx.user.id;
|
const userId = ctx.user.id;
|
||||||
|
|
||||||
return await deleteDocument({ id, userId, status });
|
return await deleteDocument({
|
||||||
|
id,
|
||||||
|
userId,
|
||||||
|
status,
|
||||||
|
requestMetadata: extractNextApiRequestMetadata(ctx.req),
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(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
|
setTitleForDocument: authenticatedProcedure
|
||||||
.input(ZSetTitleForDocumentMutationSchema)
|
.input(ZSetTitleForDocumentMutationSchema)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
|||||||
@ -1,8 +1,21 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { URL_REGEX } from '@documenso/lib/constants/url-regex';
|
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';
|
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({
|
export const ZGetDocumentByIdQuerySchema = z.object({
|
||||||
id: z.number().min(1),
|
id: z.number().min(1),
|
||||||
teamId: z.number().min(1).optional(),
|
teamId: z.number().min(1).optional(),
|
||||||
|
|||||||
@ -3,10 +3,11 @@ import { z } from 'zod';
|
|||||||
import { PROTECTED_TEAM_URLS } from '@documenso/lib/constants/teams';
|
import { PROTECTED_TEAM_URLS } from '@documenso/lib/constants/teams';
|
||||||
import { TeamMemberRole } from '@documenso/prisma/client';
|
import { TeamMemberRole } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
// Consider refactoring to use ZBaseTableSearchParamsSchema.
|
||||||
const GenericFindQuerySchema = z.object({
|
const GenericFindQuerySchema = z.object({
|
||||||
term: z.string().optional(),
|
term: z.string().optional(),
|
||||||
page: z.number().optional(),
|
page: z.number().min(1).optional(),
|
||||||
perPage: z.number().optional(),
|
perPage: z.number().min(1).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -143,14 +143,17 @@ const sheetVariants = cva(
|
|||||||
|
|
||||||
export interface DialogContentProps
|
export interface DialogContentProps
|
||||||
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||||
VariantProps<typeof sheetVariants> {}
|
VariantProps<typeof sheetVariants> {
|
||||||
|
showOverlay?: boolean;
|
||||||
|
sheetClass?: string;
|
||||||
|
}
|
||||||
|
|
||||||
const SheetContent = React.forwardRef<
|
const SheetContent = React.forwardRef<
|
||||||
React.ElementRef<typeof SheetPrimitive.Content>,
|
React.ElementRef<typeof SheetPrimitive.Content>,
|
||||||
DialogContentProps
|
DialogContentProps
|
||||||
>(({ position, size, className, children, ...props }, ref) => (
|
>(({ position, size, className, sheetClass, showOverlay = true, children, ...props }, ref) => (
|
||||||
<SheetPortal position={position}>
|
<SheetPortal position={position}>
|
||||||
<SheetOverlay />
|
{showOverlay && <SheetOverlay className={sheetClass} />}
|
||||||
<SheetPrimitive.Content
|
<SheetPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(sheetVariants({ position, size }), className)}
|
className={cn(sheetVariants({ position, size }), className)}
|
||||||
|
|||||||
@ -55,6 +55,8 @@
|
|||||||
--card-border-tint: 112 205 159;
|
--card-border-tint: 112 205 159;
|
||||||
--card-foreground: 0 0% 95%;
|
--card-foreground: 0 0% 95%;
|
||||||
|
|
||||||
|
--widget: 0 0% 14.9%;
|
||||||
|
|
||||||
--border: 0 0% 27.9%;
|
--border: 0 0% 27.9%;
|
||||||
--input: 0 0% 27.9%;
|
--input: 0 0% 27.9%;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user